FFCA FAQ
Most of FFCA follows from the layer rules. However, a few situations come up often enough, and have a non-obvious answer, that they’re worth writing down. These are the questions we get asked the most.
Should we use callable classes for Commands and Queries?
Section titled “Should we use callable classes for Commands and Queries?”No. Callable classes look great at the call site. However, they break code navigation. For example, if you try to “find all usages” of a call method, Dart mixes different callable classes together. If you try to jump to the call method, you are unable to do so unless you explicitly use call.
Therefore, please use the following verbs:
- Commands use an
executemethod. - Queries use a
getorwatchmethod, depending on whether it returns aFutureor aStream.
How do I handle auth and user profiles?
Section titled “How do I handle auth and user profiles?”Identity, meaning authentication, and entity, such as a user profile, are two separate domains. Therefore, each should be a separate feature.
flowchart
subgraph auth_domain["auth_domain"]
IAuthRepository["IAuthRepository"]
AuthUser["AuthUser"]
end
subgraph user_profile_domain["user_profile_domain"]
IUserProfilesRepository["IUserProfilesRepository"]
UserProfile["UserProfile"]
Query["WatchCurrentUserProfileQuery"]
end
Query -->|"watchCurrentUser()"| IAuthRepository
Query -->|"watchUserProfile(authUser.id)"| IUserProfilesRepository
IAuthRepository -->|"emits"| AuthUser
IUserProfilesRepository -->|"emits"| UserProfile
Query -->|"returns"| UserProfile
If the Auth feature returns a full UserProfile object, with bio, address, settings, and so on, you couple your authentication logic to your business data. This means every time you add a field to the user profile, such as themePreference, you have to modify the Auth feature. That breaks the single responsibility principle and the bounded contexts of each feature.
Therefore, it’s recommended to split your AuthUser from a UserProfile object in different feature packages.
auth_domain:
class AuthUser { const AuthUser({ required this.id, required this.email, this.isEmailVerified = false, });
final String id; final String email; final bool isEmailVerified;}
abstract interface class IAuthRepository { Future<AuthUser?> getCurrentUser(); Stream<AuthUser?> watchCurrentUser();}Optionally, include minimal claims on AuthUser if your auth provider, such as Firebase or Auth0, gives them to you for free. Anything beyond that belongs in the profile.
user_profile_domain:
class UserProfile { const UserProfile({ required this.id, required this.username, required this.avatarUrl, });
final String id; final String username; final String avatarUrl;}
abstract interface class IUserProfilesRepository { Future<UserProfile> getUserProfile(String userId); Stream<UserProfile> watchUserProfile(String userId);}In order to glue them together and return information about the current user, add a Query inside the user_profile_domain. This one uses switchMap from rxdart:
class WatchCurrentUserProfileQuery { WatchCurrentUserProfileQuery({ required IAuthRepository authRepository, required IUserProfilesRepository userProfilesRepository, }) : _authRepository = authRepository, _userProfilesRepository = userProfilesRepository;
final IAuthRepository _authRepository; final IUserProfilesRepository _userProfilesRepository;
Stream<UserProfile?> watch() { return _authRepository.watchCurrentUser().switchMap((authUser) { if (authUser == null) { // Signed out, so there is no profile to watch. return Stream.value(null); }
// Signed in, so switch to watching this user's profile. return _userProfilesRepository.watchUserProfile(authUser.id); }); }}Notice that the user_profile feature depends on auth_domain, and auth knows nothing about profiles.
What if the backend has one large OpenAPI or Swagger definition for every endpoint?
Section titled “What if the backend has one large OpenAPI or Swagger definition for every endpoint?”Generate a Dart package that knows how to talk to the Swagger client in the shared folder. As part of the package, it should start with a Dart script in the tool folder. The script should:
- Download the latest version of the OpenAPI or Swagger spec.
- Generate the client with a package like swagger_to_dart. It uses Dio, so you get the same HTTP client as the
api_clientrecipe in the Very Good Flutter Cookbook. - Export the parts each feature needs.
Each feature then uses that shared client inside its own data package. Given a /products/{id} endpoint returning an ApiProduct DTO, product_data exposes a ProductsRepository with a getProductById method that:
- Checks whether the product is already in the local database.
- If not, fetches it with the shared client.
- Stores it in the database.
- Maps the
ApiProductDTO to the domainProduct.
This way, the shared package stays generic, and every product-specific decision stays in the feature.
Dealing with nested objects
Section titled “Dealing with nested objects”Often times, APIs will also return nested objects. For example, you might have a Photo object with an embedded User. This is a classic “normalized cache vs. nested API response” problem.
Say you want to fetch an ApiPhoto from the API, store the photo and the user in separate local tables, and return a PhotoSummary object which only contains the userId, not the complete User object.
The responsibility of normalizing data, meaning the decision to store a User in one table and a Photo in another, belongs strictly to the data layer. The domain layer shouldn’t know you are normalizing your cache. It just wants a PhotoSummary. Therefore, rather than creating a Query to handle this logic, keep it in the PhotosRepository.
Here’s how it works. PhotosRepository, in photos_data, depends on IUsersRepository from users_domain. This is a valid dependency, data to domain. When the API returns the nested JSON, the repository strips out the User data and sends it to the IUsersRepository before saving the Photo.
class PhotosRepository implements IPhotosRepository { PhotosRepository({ required SwaggerRemoteDataSource remoteDataSource, required PhotosLocalDataSource photosLocalDataSource, required IUsersRepository usersRepository, }) : _remoteDataSource = remoteDataSource, _photosLocalDataSource = photosLocalDataSource, _usersRepository = usersRepository;
/// The generated Swagger client from the shared folder. final SwaggerRemoteDataSource _remoteDataSource;
/// The local data source, such as a Drift database. final PhotosLocalDataSource _photosLocalDataSource;
/// From the users_domain feature. final IUsersRepository _usersRepository;
@override Future<PhotoSummary> fetchPhotoById(String id) async { // The DTO contains an embedded user. final apiPhoto = await _remoteDataSource.getPhotoById(id);
// This mapping lives in photos_data. Avoid importing it from users_data. // That duplicates a little code, and it keeps photos_data decoupled from // users_data. We trade DRY for bounded contexts here on purpose. final user = apiPhoto.user.toDomain(); await _usersRepository.saveUser(user);
// Save the photo with only the userId, as a PhotoSummary. final photoSummary = apiPhoto.toDomain(); await _photosLocalDataSource.savePhotoSummary(photoSummary);
return photoSummary; }}sequenceDiagram
participant API as Remote Data Source
participant Repo as PhotosRepository<br/>(photos_data)
participant UsersRepo as IUsersRepository<br/>(users_domain)
participant LocalDB as PhotosLocalDataSource
Note over Repo: fetchPhotoById(id)
Repo->>API: getPhotoById(id)
API-->>Repo: ApiPhoto { photo, user: ApiUser }
Note over Repo: Strip and normalize nested data
Repo->>Repo: apiPhoto.user.toDomain()
Repo->>UsersRepo: saveUser(user)
Repo->>Repo: apiPhoto.toDomain()
Repo->>LocalDB: savePhotoSummary(photoSummary)
Repo-->>Repo: return PhotoSummary