Project Miniclient — Utility layer

Oleksandr Leushchenko
Tide Engineering Team
14 min read4 days ago

That’s the second part of the Project Miniclient tutorial. If you missed the beginning, you may want to check the introduction first.

Every utility package can rely solely on another utility package. This constitutes the foundational level of our project, which is not directly tied to the project itself and can be reused in other applications.

Linter — tide_analysis

Linter is a valuable tool for developers as it promotes code quality, enforces best practices, prevents bugs, and improves overall development efficiency. Linter helps create maintainable, reliable, and consistent applications. When working on large projects or collaborating with a team, adhering to a set of coding standards is crucial for consistency. The Tide project benefits from the collaborative efforts of over 60 mobile developers. Ensuring a consistent codebase across its several sub-teams is crucial, and one of the most significant means to achieve this is through using linter.

  1. Create a folder called tide_analysis, inside the utility folder.
  2. Create a pubspec.yaml and a lib folder inside tide_analysis with the following content:
name: tide_analysis
description: Miniclient linter rules
version: 0.0.1
environment:
sdk: ^3.2.0 # or newer

3. Inside lib, create a file named analysis_options.yaml. This file will contain linter rules and configurations. For now, let’s add the following content:

analyzer:
exclude:
- '**/*.g.dart' # common generated files
- '**/*.freezed.dart' # generated freezed models
- '**/*.config.dart' # DI configuration files
- '**/feature**/*_test.dart' # BDD tests

language:
strict-casts: true
strict-raw-types: true

errors:
missing_required_param: error
missing_return: error
invalid_assignment: warning
invalid_annotation_target: ignore

linter:
rules:
always_declare_return_types: true
always_put_control_body_on_new_line: true
always_use_package_imports: true
annotate_overrides: true
directives_ordering: true
null_check_on_nullable_type_parameter: true
package_names: true
package_prefixed_library_names: true
parameter_assignments: true
unnecessary_await_in_return: true
unnecessary_brace_in_string_interps: true
unnecessary_const: true
unnecessary_constructor_name: true
unnecessary_getters_setters: true
unnecessary_library_directive: true
unnecessary_new: true
unnecessary_null_aware_assignments: true
unnecessary_null_checks: true
unnecessary_null_in_if_null_operators: true
unnecessary_nullable_for_final_variable_declarations: true
unnecessary_overrides: true
unnecessary_parenthesis: true

4. Congratulations! You’ve successfully implemented a package with custom linter rules! You’ll use this package in each and every other package you create further.

Bonus exercise

There are many more useful rules available in the analyzer. Check https://dart.dev/tools/linter-rules and enable rules you find useful.

Foundation components — tide_prelude

The term “prelude” is often used in software development to refer to a module or package that contains the core functionality or foundational components of an application. It is inspired by the musical term “prelude,” which refers to an introductory piece of music that sets the tone for what follows.

In the context of mobile application development, developers may use the term “prelude” to represent a module or package that provides essential features, utilities, or commonly used functionalities for the application.

We will create the bare minimum prelude implementation. It will have only one class — Result.

Result (or Either) is a class that is especially useful when a function can return either a result or an error.

Here is your new task:

  1. Create a “tide_prelude” folder under utility.
  2. Create a “pubspec.yaml” file in it:
name: tide_prelude
description: Foundation components
version: 0.0.1
publish_to: none

environment:
sdk: ^3.2.0

dev_dependencies:
tide_analysis:
path: ../tide_analysis

3. Create “analysis_options.yaml” file in the root folder of the package (near just created “pubspec.yaml”) with the following content:

include: package:tide_analysis/analysis_options.yaml

tide_analysis” will always be in dev_dependencies and each package in our project must have this “analysis_options.yaml” with the same content. We assume you will add this file from now on, otherwise, linter will not work.

4. Create a “lib” folder with a “src” folder in it. All code that we put under the “src” folder will be encapsulated in the package, hence we tend to put as many files as we can under “src”. Create a “result.dart” file under “src” with the following content:

/// This class will be used by class users in their methods
sealed class Result<S, F> {
const Result._();

B fold<B>(B Function(S s) ifSuccess, B Function(F f) ifFailure);
}

/// A container for Failure values
class Failure<S, F> extends Result<S, F> {
const Failure._(this._f) : super._();

final F _f;

F get value => _f;

@override
B fold<B>(B Function(S s) ifSuccess, B Function(F f) ifFailure) =>
ifFailure(_f);

@override
bool operator ==(Object other) => other is Failure && other._f == _f;

@override
int get hashCode => _f.hashCode;
}

/// A container for Success values
class Success<S, F> extends Result<S, F> {
const Success._(this._s) : super._();

final S _s;

S get value => _s;

@override
B fold<B>(B Function(S s) ifSuccess, B Function(F f) ifFailure) =>
ifSuccess(_s);

@override
bool operator ==(Object other) => other is Success && other._s == _s;

@override
int get hashCode => _s.hashCode;
}

Result<S, F> failure<S, F>(F f) => Failure._(f); // helper function
Result<S, F> success<S, F>(S s) => Success._(s); // helper function

Here, we have one base class Result with two implementations — Success and Failure. When you need to return a value from a function that potentially can throw an Exception or may return an unexpected result, you should use Result as a return value, like this:

Result<int, String> divide(int a, int b) {
if (b == 0) {
return failure('Cannot divide by zero');
}
return success(a ~/ b);
}

fold takes two functions as parameters: one for handling a success value and one for handling an error value. It applies the appropriate function based on whether the Result represents a success or an error. This is the most important function of the class as it is the only way to get a value from the Result monad.

final value = divide(2, 1).fold(
(r) => r, // returns the result if division succeeded
(e) => double.negativeInfinity, // returns negative inf if division failed
);

Why use Result and not Tuple or Record? I’m glad you asked! There are several reasons for that:

  1. Semantic clarity: The Result type communicates intent. It’s immediately clear that the function can return either a success or an error, and the Result type forces you to handle both cases. With a Tuple, it’s less clear: it’s not immediately obvious what each element of the tuple represents without additional documentation, and it’s easy to forget to handle the error case.
  2. Type safety: The Result type is more type-safe. In a Tuple, both the success value and the error value are always present, and it’s up to you to remember which one is valid. With the Result type, only one of them is present.
  3. Functionality: The Result type comes with useful methods for handling common scenarios. For example, you might have a method to map over the success value, a method to provide a default value in case of an error, etc. With a Tuple, you have to implement this functionality yourself every time.
  4. Consistency: If you use the Result type for all functions that can fail, your code will be more consistent and easier to understand. With Tuple, different functions might use different conventions (e.g., is the first element of the tuple the success value or the error value?).

It’s time to enrich Result’s functionality. We’ll add a few important functions:

import 'dart:async';

sealed class Result<S, F> {
...
/// This function applies a transformation to the success value of the Result,
/// and returns a new Result that contains the transformed value. If the Result
/// represents an error, the transformation is not applied and
/// the error is propagated to the new Result.
Result<S2, F> map<S2>(S2 Function(S s) f) =>
fold((S s) => success(f(s)), failure);

/// Similar to map, but the transformation function returns a Result
/// rather than a raw value. This is useful when the transformation itself can fail.
Result<S2, F> flatMap<S2>(Result<S2, F> Function(S s) f) => fold(f, failure);

/// An asynchronous version of flatMap.
/// The transformation function is asynchronous (returns a Future<Result>) and
/// so is asyncFlatMap itself.
Future<Result<S2, F>> asyncFlatMap<S2>(
Future<Result<S2, F>> Function(S s) f,
) =>
fold(f, (error) => Future.value(failure(error)));

/// Similar to map, but doesn't return a new Result. Instead, it simply applies
/// a function for its side effects and returns nothing. If the Result is an error,
/// the function is not applied.
FutureOr<void> forEach<T>(FutureOr<T> Function(S) f) => fold(f, (_) {});

/// Returns the success value if the Result represents a success, and defaultValue's
/// result if it represents an error.
S getOrElse(S Function() defaultValue) =>
fold((s) => s, (_) => defaultValue());

/// Returns the success value if the Result represents a success, and null
/// if it represents an error.
S? getOrNull() => fold((s) => s, (_) => null);

/// Constructs a Result from a Future. If the Future completes successfully,
/// the Result is a success with the value of the Future. If the Future fails,
/// the Result is an error with the error of the Future.
static Future<Result<T, Exception>> fromAsync<T>(
Future<T> Function() func,
) async {
try {
final result = await func();
return success(result);
} on Exception catch (e) {
return failure(e);
// ignore: avoid_catching_errors
} on Error catch (e) {
return failure(Exception('${e.runtimeType}: ${e.stackTrace}'));
}
}

/// Constructs a Result by running a function. If the function returns normally,
/// the Result is a success with the return value of the function. If the function
/// throws an exception, the Result is an error with the thrown exception.
static Result<T, Exception> fromAction<T>(T Function() func) {
try {
final result = func();
return success(result);
} on Exception catch (e) {
return failure(e);
// ignore: avoid_catching_errors
} on Error catch (e) {
return failure(Exception('${e.runtimeType}: ${e.stackTrace}'));
}
}

/// Constructs a Result from a value that may be null. If the value is non-null,
/// the Result is a success with that value. If the value is null, the Result
/// is an error.
static Result<T, Exception> fromNullable<T>(
T? value,
Exception Function() onError,
) {
if (value != null) {
return success(value);
} else {
return failure(onError());
}
}

/// Constructs a Result by testing a condition. If the condition is true,
/// the Result is a success with a certain value. If the condition is false,
/// the Result is an error with a certain error.
static Result<T, Exception> fromPredicate<T>(
bool condition,
T Function() onSuccess,
Exception Function() onError,
) =>
condition ? success(onSuccess()) : failure(onError());
}

Tip: Do not blindly copy-paste the above code. Try to understand how it works, you will need this knowledge later. 😉

5. To make the Result available outside of the tide_prelude package, create a “tide_prelude.dart” barrel file under “lib” folder with the following content:

export 'package:tide_prelude/src/result.dart';

We use barrel files to export parts of functionality from packages. In the next section, when we ask you to create a file, you should always put it under the “src” folder. The only way to make a class visible outside the package is by exporting it via barrel file. Keep in mind that not everything should be exported. In fact, the less you export the better. Do not want to write barrel files yourself? Consider using a barrel files generator.

Bonus exercise

  1. Find at least one real-life usage example for each Result function. Write at least one unit test for each function.
  2. Result often comes with a simpler monad called “Optional”. They both represent a way to handle computations that may not return a value. However, they are used in slightly different scenarios: Optional is used when an operation might not have a result, whereas Result is used when an operation might fail. A failure, represented by Result, usually means there was an error and contains information about what went wrong, whereas a lack of value (represented by Optional) simply means that there isn’t anything there.
    What would Optional implementation look like? Draft some.
    Hint: it is similar to Result.

DI — tide_di

Dependency injection is a must to keep a project flexible and easier to maintain. By providing dependencies to classes we allow them to be easily tested with mocks and easier to scale, as in many cases a dependency can be shared by multiple classes.

To resolve the problem of providing easy access to services, our tool of choice is get_it. It follows the service locator pattern, so by registering all the dependencies in one place, it makes it much easier to retrieve them when needed.

To manage get_it configuration, we use injectable which works in combination with build_runner. The code it generates registers annotated classes in get_it for later usage in other parts of the app.

Create a “tide_di” folder under utility, add “pubspec.yaml” with the following content:

name: tide_di
description: Tide's dependency injection mechanism
version: 0.1.0
publish_to: none

environment:
sdk: ^3.2.0

dependencies:
get_it: ^7.6.4

dev_dependencies:
tide_analysis:
path: ../tide_analysis

2. Make sure that the custom analyzer rules work for this new project.
Hint: if you forgot how to do that, read the “Foundation components — tide_prelude” section.

3. Create “lib/src/tide_di_initializer.dart” file (remember our agreement regarding barrel files? If not — refer to the “Foundation components — tide_prelude” section):

import 'dart:async';
import 'package:get_it/get_it.dart';

typedef GetItInitializer = FutureOr<void> Function(
GetIt getIt,
String? environment,
);

class TideDIInitializer {
const TideDIInitializer(this._initializer);

final GetItInitializer _initializer;

FutureOr<void> init(GetIt getIt) => _initializer(getIt, null);
}

In the next section you’ll see how “TideDIInitializer” instances are used to configure “get_it” in a modular way

4. Create a new file named “lib/src/tide_di_container.dart” with the following content:

import 'package:get_it/get_it.dart';
import 'package:tide_di/src/tide_di_initializer.dart';

final diContainer = _TideDIContainer(_getIt);

Future<DIContainer> initializeDIContainer(
List<TideDIInitializer> initializers,
) async {
for (final initializer in initializers) {
await initializer.init(_getIt);
}
return diContainer;
}

abstract class DIContainer {
T call<T extends Object>({dynamic parameter, String? name});

bool isRegistered<T extends Object>({String? name});
}

final _getIt = GetIt.instance;

class _TideDIContainer implements DIContainer {
const _TideDIContainer(this._getIt);

final GetIt _getIt;

@override
T call<T extends Object>({dynamic parameter, String? name}) =>
_getIt<T>(param1: parameter, instanceName: name);

@override
bool isRegistered<T extends Object>({String? name}) =>
_getIt.isRegistered<T>(instanceName: name);
}

5. Just like with the “tide_prelude” package, all files in this package are declared package-private, which means they are not visible to the package’s users. We use this mechanism for all packages. To make classes visible from the outside, create the following file “lib/tide_di.dart”:

export 'package:tide_di/src/tide_di_container.dart';
export 'package:tide_di/src/tide_di_initializer.dart';

Bonus exercise

  1. What other options could we use instead of get_it? After completing the manual, replace get_it with any other service locator of your choice.

Networking — api_client

Networking is a critical part of any application. In this section, we will work on the API client, a package that would allow us to do network operations.

  1. Traditionally, start with creating a new “utility/api_client” folder with a “pubspec.yaml” file:
name: api_client
description: Marvel API client
publish_to: none
version: 0.0.1

environment:
sdk: ^3.2.0

dependencies:
crypto: ^3.0.3
dio: ^5.4.0
injectable: ^2.3.2
json_annotation: ^4.8.1
tide_prelude:
path: ../tide_prelude
retrofit: ^4.0.3
tide_di:
path: ../tide_di

dev_dependencies:
build_runner: ^2.4.7
injectable_generator: ^2.4.1
json_serializable: ^6.7.1
retrofit_generator: ^8.0.4
tide_analysis:
path: ../../utility/tide_analysis

2. For the last time, we will remind you about turning on the custom linter rules for this new package.

3. Most APIs have sort of a wrapper for response models and Marvel API is not an exception. Let’s define this wrapper response model. Create a file “lib/src/model/marvel_reponse.dart”:

import 'package:json_annotation/json_annotation.dart';

part 'marvel_response.g.dart';

@JsonSerializable(genericArgumentFactories: true, constructor: '_')
class MarvelResponse<T> {
const MarvelResponse._({required this.data});

factory MarvelResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) =>
_$MarvelResponseFromJson(json, fromJsonT);

@JsonKey(name: 'data')
final T data;
}

4. After creating the model, you will see compilation errors. That’s because you need to run code generation mechanism to complete the implementation, you can do that with the following command:

dart pub run build_runner build -d

5. The API is protected with public and private API keys. Go here and grab one pair for yourself. Once you’re done, create a “lib/src/di/di_parameter_name.dart” file with the following content:

abstract class ApiClientDiParameterName {
static const privateApiKey = 'ApiClientPrivateApiKey';
static const publicApiKey = 'ApiClientPublicApiKey';
}

Now register API key values in DI. Create a “lib/src/di/di_module.dart” file with the following content:

import 'package:api_client/src/di/di_parameter_name.dart';
import 'package:injectable/injectable.dart';

@module
abstract class DioModule {
@Named(ApiClientDiParameterName.privateApiKey)
String get privateApiKey => const String.fromEnvironment('MARVEL_PRIVATE_API_KEY');

@Named(ApiClientDiParameterName.publicApiKey)
String get publicApiKey => const String.fromEnvironment('MARVEL_PUBLIC_API_KEY');
}

6. To interact with the API, we must provide various parameters in request headers. We’ll implement an interceptor that will add them automatically. Create a “lib/src/api/marvel_api_auth_interceptor.dart” file with the following content:

import 'dart:convert';

import 'package:api_client/src/di/di_parameter_name.dart';
import 'package:crypto/crypto.dart' as crypto;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';

@lazySingleton
class MarvelApiAuthInterceptor extends Interceptor {
MarvelApiAuthInterceptor(
@Named(ApiClientDiParameterName.privateApiKey) this._privateApiKey,
@Named(ApiClientDiParameterName.publicApiKey) this._publicApiKey,
);

final String _publicApiKey;
final String _privateApiKey;

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();

options.queryParameters['ts'] = timestamp;
options.queryParameters['apikey'] = _publicApiKey;
options.queryParameters['hash'] =
_md5('$timestamp$_privateApiKey$_publicApiKey');

super.onRequest(options, handler);
}
}

String _md5(String input) => crypto.md5.convert(utf8.encode(input)).toString();

7. The next entity we define in this package would be a dio factory. Firstly, we need to define a module with the factory. Modify the “lib/src/di/dio_module.dart” file like this:

import 'package:api_client/src/api/marvel_interceptor.dart';
import 'package:dio/dio.dart';
...

@module
abstract class DioModule {
...

@lazySingleton
Dio dio(
MarvelApiAuthInterceptor marvelApiAuthInterceptor,
) =>
Dio(BaseOptions(baseUrl: 'https://gateway.marvel.com:443/'))
..interceptors.add(marvelApiAuthInterceptor);
}

8. To make this factory available in the app later, we will use a TideDIInitializer class we wrote in the “DI — tide_di” section to register the factory in our global DI scope. Create a “lib/src/di/di_initializer.dart” file with the following content:

import 'dart:async';

import 'package:api_client/src/di/di_initializer.config.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:tide_di/tide_di.dart';

class ApiClientDIInitializer extends TideDIInitializer {
const ApiClientDIInitializer() : super(_init);
}

@injectableInit
FutureOr<GetIt> _init(GetIt getIt, String? environment) =>
getIt.init(environment: environment);

9. Run code generation for the package.

10. It is time to decide what classes will be visible for the “api_client” package users. Create a “lib/api_client.dart” file with the following content:

export 'package:api_client/src/di/di_initializer.dart';
export 'package:api_client/src/model/marvel_response.dart';

Bonus exercise

  1. Make it possible to change the base URL during compilation time. What changes needed to be made to the DI initialization?
  2. Read about SSL pinning in the dio documentation and implement one for this API.
  3. Read about the build.yaml file. Use it to optimize code generation.

Monitoring — tide_monitoring

Monitoring tools are software tools that help developers track and analyze the behaviour, performance, and usage of applications. These tools provide valuable insights into the application’s runtime behaviour, identify potential issues, and help improve the overall user experience.

For the MiniClient project, we have determined that utilizing Firebase as a monitoring tool is just enough, despite the availability of various other monitoring options.

  1. To start integrating Firebase into the MiniClient project, create a “tide_monitoring” package within a utility folder with the following “pubspec.yaml” file:
name: tide_monitoring
description: Monitoring
publish_to: none
version: 0.0.1

environment:
sdk: ^3.2.0
flutter: ^3.16.3

dependencies:
firebase_core: ^2.24.1
firebase_crashlytics: ^3.4.7
flutter:
sdk: flutter
injectable: ^2.3.2
tide_di:
path: ../tide_di

dev_dependencies:
build_runner: ^2.4.7
injectable_generator: ^2.4.1
tide_analysis:
path: ../../utility/tide_analysis

2. Follow the instructions provided in the official Firebase documentation for Flutter setup. Add it to the application itself (for now, it’s just the default counter app). Once you complete the integration, move the “firebase_options.dart” file to the “utility/tide_monitoring/lib/src” folder.

3. Once Firebase integration is working, add a “lib/src/monitoring_module.dart” file for registering Firebase:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:tide_monitoring/src/firebase_options.dart';

@module
abstract class MonitoringModule {
@singleton
@preResolve
Future<FirebaseCrashlytics> getCrashlytics() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
return FirebaseCrashlytics.instance;
}
}

4. Now, create a “lib/src/monitoring.dart” file with the following content:

import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:injectable/injectable.dart';

@lazySingleton
class Monitoring {
const Monitoring(this._firebase);

final FirebaseCrashlytics _firebase;

Future<void> log(String message) async {
await _firebase.log('log: $message');
}
}

5. Create DI initializer, put the following content into “lib/src/di/di_initializer.dart”:

import 'dart:async';

import 'package:monitoring/src/di/di_initializer.config.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:tide_di/tide_di.dart';

class MonitoringDIInitializer extends TideDIInitializer {
const MonitoringDIInitializer() : super(_init);
}

@injectableInit
FutureOr<GetIt> _init(GetIt getIt, String? environment) =>
getIt.init(environment: environment);

6. Monitoring implementation is complete. Create a “lib/tide_monitoring.dart” file and export the Monitoring class and DI initializer from the package.

export 'package:tide_monitoring/src/di/di_initializer.dart';
export 'package:tide_monitoring/src/monitoring.dart';

Bonus exercise

  1. What methods might be handy except the “log” we’ve created? Implement a few.
  2. If you’re seeking a challenging opportunity, consider exploring and integrating Datadog (or any other service you prefer) as an additional monitoring option. Make the tide_monitoring package a wrapper for both Firebase and Datadog.
  3. What other Firebase features might be useful for our application? Consider creating more packages, similar to Monitoring.

Design system — tide_design_system

A Design System is a collection of reusable components, patterns, guidelines, and standards that define the visual and interactive elements of an application. It provides a consistent and cohesive user interface (UI) and user experience (UX) across different screens and interactions within the app.

  1. Create the “tide_design_system” package under the “utility” folder with the following “pubspec.yaml” file:
name: tide_design_system
description: Design system
version: 0.0.1
publish_to: none

environment:
sdk: ^3.2.0
flutter: ^3.16.3

dependencies:
flutter:
sdk: flutter

dev_dependencies:
tide_analysis:
path: ../tide_analysis

flutter:
uses-material-design: true

2. The design system defines the look’n’feel of the application. In Flutter, there is a special mechanism for that called Themes. Let’s define a theme for our application and create a “lib/src/miniclient_theme.dart” file:

import 'package:flutter/material.dart';

final miniclientThemeData = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
);

3. Additionally, the design system can have a few commonly used widgets for the application. Let’s define standard loading and error widgets for the whole app.
Create “lib/src/widget/loading.dart” file:

import 'package:flutter/material.dart';

class Loading extends StatelessWidget {
const Loading({super.key});

@override
Widget build(BuildContext context) => const Center(
child: CircularProgressIndicator(),
);
}

Create a “lib/src/widget/error.dart” file:

import 'package:flutter/material.dart';

class Error extends StatelessWidget {
const Error({super.key, required this.message});

final String message;

@override
Widget build(BuildContext context) => Center(
child: Text(message),
);
}

4. Make all these files available for usage outside of the package via the barrel file, create “lib/tide_design_system.dart”:

export 'package:tide_design_system/src/miniclient_theme.dart';
export 'package:tide_design_system/src/widget/error.dart';
export 'package:tide_design_system/src/widget/loading.dart';

Bonus exercise

  1. Investigate what is possible to change via ThemeData, customize the theme for your application.
  2. What other common widgets do you think we may have in the design system? Implement a few.

That’s it for the utility layer. It is time to work on a feature now!

About Tide

Founded in 2015 and launched in 2017, Tide is the leading business financial platform in the UK. Tide helps SMEs save time (and money) in the running of their businesses by not only offering business accounts and related banking services, but also a comprehensive set of highly usable and connected administrative solutions from invoicing to accounting. Tide has 600,000 SME members in the UK (more than 10% market share) and more than 275,000 SMEs in India. Tide has also been recognised with the Great Place to Work certification.
Tide has been funded by Anthemis, Apax Partners, Augmentum Fintech, Creandum, Salica Investments, Jigsaw, Latitude, LocalGlobe, SBI Group and Speedinvest, amongst others. It employs around 1,800 Tideans worldwide. Tide’s long-term ambition is to be the leading business financial platform globally.

LinkedIn

--

--