The Domain Layer
Welcome to the heart of your project – the Domain Layer. This is where all of your core business-logic resides.
Entities: The Core Components
Entities are the building blocks of your app's state, existing as long as their respective Use Cases do. Here are some guidelines for creating robust entities:
- Independence: Entities should only depend on the clean framework import and not on external libraries or other features. Since the Entity layer is the innermost of all the Clean Architecture layers, it shouldn't depend on anything!
- Immutability: Attributes should be final, with initial values set during construction. They can also be required, being given values at Use Case creation. This will be explained further in the next section.
- Data Types: Use proper data types (e.g., DateTime instead of String for dates) instead of relying on parsers. Parsing can be done in Presenters or Gateways.
- Hierarchy and Composition: It is OK to create a hierarchy of entities, but keep a single ancestor that the Use Case can create easily. Prefer composition over inheritance. Utilize constructs like Either and Unions for flexibility.
- Convenience Methods: Implement generator methods like
copyWith
for easy state management. This simplifies writing code in the Use Case.
Entity Example:
class AccountEntity extends Entity {
AccountEntity({this.isRegistered = false, this.userName});
final bool isRegistered;
final UserNameEntity? userName;
}
class UserNameEntity extends Entity{
UserNameEntity({required this.firstName, required this.lastName})
: assert(firstName.isNotEmpty && lastName.isNotEmpty);
final String firstName;
final String lastName;
String get fullName => '$firstName $lastName';
}
In this example, note that it is nearly impossible for a developer to create an inconsistent username with null or empty data (e.g. first or last name). It is alright to add methods to validate the consistency of the data, or methods such as dynamic getters that retrieve data from multiple fields.
This has a few advantages:
-
It's like a safety net for developers. By catching syntax errors or exceptions early on, you're less likely to write incorrect code around these entity fields. This makes testing and coding a smoother process.
-
It keeps things organized. Instead of mixing up custom logic for composing fields with the Use Case's business logic, we're assigning it to the Entity. This makes your Use Case code cleaner and easier to understand.
Pro tip: Offload those helper methods to the Entity, especially when they're just working with data. This includes stuff like form validations, math calculations, and so on. It's all about keeping it neat and tidy!
What's a Use Case? Use Cases sit outside Entities, in their own layer. They manage the core state of your feature (the Entity), and can be interacted with using DomainInputs (bringing data in) and DomainModels (moving data out). Let's jump into an example to make this clearer:
class MyUseCase extends UseCase<MyEntity> {
MyUseCase()
: super(
entity: MyEntity(),
transformers: [
DomainModelTransformer.from(
(entity) => MyDomainToUIModel(data: entity.data),
),
DomainInputTransformer<MyEntity, MyDomainInput>.from(
(entity, domainInput) => entity.copyWith(data: domainInput.data),
),
],
);
}
In any given Use Case, you'll need to create an Entity. You can also set up domain input and domain model filters as shown above, which are essentially different "channels" that Presenters subscribe to for updates.
Let's take the example of MyUseCase
, which has a single domain model. In this case, the Presenter only needs to tune into updates from MyDomainToUIModel
instances. These instances are generated when the Presenter is initialized and anytime there's a change in the Entity's data field .
The filtering mechanism is pretty straightforward. It uses a Map that associates a DomainModel type with a function that processes the current Entity instance. This approach simplifies the coding process, helping developers focus on straightforward logic and avoid complex method calls.
It's important to note that DomainModels are designed to contain only a portion of the Entity's data. The Presenter and Use Case communicate in such a way that a new Domain Model is generated only if there's a change in the specific fields used to create it. For instance, even if MyUseCase
modifies the Entity, no new DomainModel will be created unless the data field changes.
Domain Input filters follow a similar pattern. When a Gateway is connected to a Use Case, it generates a specific type of Domain Input. This allows the Gateway to send MyDomainInput
instances, which are then processed by the domain input filter to update the Entity based on the new data.
So, if a MyDomainInput
instance is received, it triggers an update in the Entity's data field, leading to the creation of a new MyDomainToUIModel
.
How to Update Entities in Use Cases
You can modify Entities inside the Use Case whenever necessary:
// Method inside the Use Case
void updateAmount(double newAmount){
if (entity.isAmountValid(newAmount)) {
entity = entity.copyWith(amount: newAmount);
} else {
entity = entity.copyWith(error: Errors.invalidAmount);
}
}
The entity can be accessed from anywhere within a UseCase. Whenever you need to update even just one field of the entity, it's essential to replace the entire instance. If you don't do this, the Use Case won't produce any DomainModels. This is because it functions similarly to a ValueNotifier. This is why using generator methods such as copyWith
is so helpful!
How Domain Models Connect Use Cases with Presenters and Gateways
Use Cases are designed without specific knowledge of external layers. Their role is to create Domain Models, which can be listened to by anything. This means you should avoid making assumptions about how the data in Domain Models will be used.
For instance, a Domain Model might contain data destined for a database, for display on a screen, or to be sent to a service. It's the job of the external layers to decide the fate and application of this data.
As mentioned in our UI layer article, it might seem odd and a little redundant that we are creating multiple different data containers for (at times) the same data, and moving said data back and forth between them.
The benefit of having separate classes for the domain state (Entity
) and the transfer of data into/out of the Use Case (DomainInput
, DomainModel
) as opposed to providing other layers with the Entity directly is twofold:
- It makes clear what the data is for, where it is headed, and
- It abstracts the core state of the domain layer from the outside world, adhering to the principals of Clean Architecture and avoiding bad dependency cycles.
This way, we keep the Entity
safe from outside changes/interference and leave state management solely up to the UseCase
. Ultimately, in doing this, we make things easier to read, understand, and maintain.
Regarding the generation of Domain Models, there are two main methods. We've already discussed domain model filters, which produce Domain Models when there's a change in the entity.
The second method involves creating Domain Models on demand. This is used when the Use Case needs to wait for a response or some action from the external layers, and looks like this:
void fetchUserData(){
await request(
FetchUserDataDomainToGatewayModel(),
onSuccess: (UserDataDomainInput input) {
return entity.copyWith(name: input.name);
},
onFailure: (_) {
return entity.copyWith(error: Error.dataFetchError);
},
);
}
The request method generates a Future that publishes a FetchUserDataDomainToGatewayModel instance. If this domain model type isn't being listened to, an error is thrown. During development, you can use dummy Gateways while developing the Use Case behavior before writing external code.
The request method includes two callbacks: one for success and another for failures.
Now, notice the onSuccess callback receives an Input. This is key because UseCase communicates with external layers exclusively through domain Domain Inputs and Domain Models. Any external data entering the class must come in as a Domain Input.
If you've already read our guide on the UI layer, hopefully you have a clearer understanding of how the Presenter interacts with the Use Case at this point, especially if you've planned which Domain Models to use in the domain model filter and Presenter.
Stay tuned for the next section of the Codelab, where we'll dive into Gateway connections.
How Inputs Connect Use Cases with Presenters and Gateways
For sending Domain Inputs to Use Cases, both Gateways and Presenters can use this method:
useCase.setInput<MyDomainInput>(MyDomainInput('foo'));
Gateways handle this process internally, but Presenters have the flexibility to use this method whenever they choose, instead of directly invoking a method on the UseCase.
File: lib/features/home/domain/home_entity.dart
import 'package:clean_framework/clean_framework.dart';
enum HomeStatus { initial, loading, loaded, failed }
class HomeEntity extends Entity {
HomeEntity({
this.pokemons = const [],
this.pokemonNameQuery = '',
this.status = HomeStatus.initial,
this.isRefresh = false,
});
final List<PokemonData> pokemons;
final String pokemonNameQuery;
final HomeStatus status;
final bool isRefresh;
@override
List<Object?> get props {
return [pokemons, pokemonNameQuery, status, isRefresh];
}
@override
HomeEntity copyWith({
List<PokemonData>? pokemons,
String? pokemonNameQuery,
HomeStatus? status,
bool? isRefresh,
}) {
return HomeEntity(
pokemons: pokemons ?? this.pokemons,
pokemonNameQuery: pokemonNameQuery ?? this.pokemonNameQuery,
status: status ?? this.status,
isRefresh: isRefresh ?? this.isRefresh,
);
}
}
class PokemonData extends Entity {
PokemonData({
required this.name,
required this.imageUrl,
});
final String name;
final String imageUrl;
@override
List<Object?> get props => [name, imageUrl];
}
Then, we'll create the HomeUseCase
. Create a new file called home_use_case.dart
inside the domain
directory:
File: lib/features/home/domain/home_use_case.dart
import 'package:clean_framework/clean_framework.dart';
import 'home_entity.dart';
class HomeUseCase extends UseCase<HomeEntity> {
HomeUseCase() : super(entity: HomeEntity());
Future<void> fetchPokemons({bool isRefresh = false}) async {
if (!isRefresh) {
entity = entity.copyWith(status: HomeStatus.loading);
}
// TODO: Make a request to fetch the pokemons
if (isRefresh) {
entity = entity.copyWith(isRefresh: false, status: HomeStatus.loaded);
}
}
}
Once you've set up the Use Case, the next step is to create a Domain to UI model, which the Presenter will use to show data on the screen. For this, you'll need to create a file named home_domain_models.dart
in the domain
directory.
File: lib/features/home/domain/home_domain_models.dart
import 'package:clean_framework/clean_framework.dart';
import 'home_entity.dart';
class HomeDomainToUIModel extends DomainModel {
HomeDomainToUIModel({
required this.pokemons,
required this.status,
required this.isRefresh,
});
final List<PokemonData> pokemons;
final HomeStatus status;
final bool isRefresh;
@override
List<Object?> get props => [pokemons, status, isRefresh];
}
Now we need to create a domain model transformer so that the raw data in Use Case (i.e. the Entity)
can be transformed into a DomainModel
.
Create the following class in the Use Case file:
File: lib/features/home/domain/home_use_case.dart
class HomeDomainToUIModelTransformer extends DomainModelTransformer<HomeEntity, HomeDomainToUIModel> {
@override
HomeDomainToUIModel transform(HomeEntity entity) {
final filteredPokemons = entity.pokemons.where(
(pokemon) {
final pokeName = pokemon.name.toLowerCase();
return pokeName.contains(entity.pokemonNameQuery.toLowerCase());
},
);
return HomeDomainToUIModel(
pokemons: filteredPokemons.toList(growable: false),
status: entity.status,
isRefresh: entity.isRefresh,
);
}
}
To handle search input from the UI for filtering the pokemons, we also need to set up a domain input and domain input transformer. So, go ahead and add these classes to the UseCase file as well:
File: lib/features/home/domain/home_use_case.dart
class PokemonSearchDomainInput extends DomainInput {
PokemonSearchDomainInput({required this.name});
final String name;
}
class PokemonSearchDomainInputTransformer extends DomainInputTransformer<HomeEntity, PokemonSearchDomainInput> {
@override
HomeEntity transform(HomeEntity entity, PokemonSearchDomainInput input) {
return entity.copyWith(pokemonNameQuery: input.name);
}
}
Finally, integrate these transformers into your Use Case:
class HomeUseCase extends UseCase<HomeEntity> {
HomeUseCase()
: super(
entity: HomeEntity(),
transformers: [
HomeDomainToUIModelTransformer(),
PokemonSearchDomainInputTransformer(),
],
);
...
}
Connecting Multiple Features - Use Case Bridges
So, now we have a Use Case set up, and it's sending and receiving data from the Presenter properly. That's great, but what if we have two features that need to share data or communicate in some way? Of course, we could just get the provider for the Use Case we want to interact with and make our function calls from wherever they're needed. However, this goes beyond the layering rules of Clean Architecture, and we run the risk of creating bad dependency cycles. Luckily, we have a Clean solution to this problem: creating a bridge between two use cases.
Setting up a Bridge Between Two Use Cases
Say we have a feature called 'home', and home needs to receive some data from another feature called 'form'. When the user types in their email on the screen for the 'form' feature, we want the 'home' feature's entity to be updated with the email address as well.
To set up a bridge between two use cases, we'll need to add it to the provider for the destination Use Case:
File: lib/providers/use_case_providers.dart
final homeUseCaseProvider =
UseCaseProvider.autoDispose<HomeEntity, HomeUseCase>(
HomeUseCase.new,
(bridge) {
bridge.connect(
formUseCaseProvider,
selector: (e) => e.userMeta.email,
(oldEmail, email) {
if (oldEmail != email) {
bridge.useCase.setInput(
LoggedInEmailDomainInput(email: email),
);
}
},
);
},
);
In the 'home' feature's UseCaseProvider, there is an optional argument for a bridge function. It receives a single argument, the UseCaseProviderBridge
. Then, we just use bridge.connect()
within the anonymous function to connect the two use cases.
UseCaseProviderBridge.connect()
bridge.connect()
takes three arguments:
- The source use case's
UseCaseProvider
, - The
selector
function, (similar toselect()
in Riverpod). This is where you specify which fields you want to end up inHomeUseCase
's entity, and it takes aFormEntity
as an argument. - An anonymous function that receives the field's old value and new value (
(oldEmail, email)
).
From here, all you need to do is set up the DomainInput
that will update HomeUseCase
with the bridge data using bridge.useCase.setInput(DomainInput)
. Now, as long as you have a DomainInputTransformer
set up in HomeUseCase
for the domain input handling the data we need (in this case, a transformer for LoggedInEmailDomainInput
), HomeEntity
will be updated with FormEntity
's email
value whenever it updates:
class LoggedInEmailDomainInputTransformer
extends DomainInputTransformer<HomeEntity, LoggedInEmailDomainInput> {
@override
HomeEntity transform(HomeEntity entity, LoggedInEmailDomainInput input) {
return entity.copyWith(loggedInEmail: input.email);
}
}
Congratulations! Your Pokemon app is getting closer to actually containing some business logic. In the next section, we'll start working on external interfaces and handling requests to interact with external services (e.g. a REST api.)