The Core Idea
A bounded context is a boundary within which a specific domain model applies.
Inside that boundary, words have precise meanings. "User" in the authentication context means something different from "User" in the chat context, which means something different from "User" in the orders context. Outside their respective boundaries, these concepts don't exist. The auth context doesn't have a shipping address. The orders context doesn't have a password hash. The chat context doesn't have a birthday.
One real-world entity — a person with a row in your database — becomes multiple domain concepts, each meaningful only within its context.
This is not a database decision. The database can have one users table with seventy columns. Bounded contexts are a domain modelling decision. The separation lives in the code, not the schema.
The Users Table, Modelled Three Ways
Here is that seventy-column users table, modelled as it actually exists in three different bounded contexts:
// ❌ The God Object — what happens without bounded contexts
class User {
final String id;
final String email;
final String passwordHash;
final DateTime lastLogin;
final bool mfaEnabled;
final String? mfaSecret;
final List<String> sessionTokens;
final String username;
final String displayName;
final String? avatarUrl;
final bool isOnline;
final String fullName;
final Address shippingAddress;
final Address? billingAddress;
final DateTime? birthday;
final DateTime registeredAt;
// ... thirty more fields nobody can remember
}Every feature that imports this class carries every field. The chat feature knows the user's billing address. The orders feature knows the user's MFA secret. Nothing is protected, nothing is scoped, and every change radiates outward.
Now the same database row, through the lens of three bounded contexts:
// Auth context — credentials, session, verification
class AuthUser {
final String id;
final String email;
final String passwordHash;
final DateTime lastLogin;
final bool mfaEnabled;
final String? mfaSecret;
// Behaviour that makes sense here
AuthUser enableMfa(String secret) {
return AuthUser(
id: id,
email: email,
passwordHash: passwordHash,
lastLogin: lastLogin,
mfaEnabled: true,
mfaSecret: secret,
);
}
bool get requiresMfaSetup => !mfaEnabled;
}// Chat context — presence, identity, display
class ChatParticipant {
final String userId;
final String username;
final String displayName;
final String? avatarUrl;
final bool isOnline;
// Behaviour that makes sense here
ChatParticipant goOnline() =>
ChatParticipant(userId: userId, username: username,
displayName: displayName, avatarUrl: avatarUrl, isOnline: true);
ChatParticipant goOffline() =>
ChatParticipant(userId: userId, username: username,
displayName: displayName, avatarUrl: avatarUrl, isOnline: false);
}// Orders context — fulfilment, delivery, billing
// Notice: not called User. In this context, the concept is Customer.
class Customer {
final String userId;
final String fullName;
final Address shippingAddress;
final Address? billingAddress;
// Behaviour that makes sense here
Customer updateShippingAddress(Address address) =>
Customer(userId: userId, fullName: fullName,
shippingAddress: address, billingAddress: billingAddress);
Address get effectiveBillingAddress =>
billingAddress ?? shippingAddress;
}Notice the orders context doesn't have a User at all. It has a Customer. That's not a naming preference — it's ubiquitous language. In the orders domain, the business talks about customers placing orders, not users. The model reflects the vocabulary of the context it lives in. This is one of the signals that you've found the right boundary: when the name feels natural to both the business and the code.
The Repository Does the Translation
The domain model is deliberately smaller than the database schema. That gap is where the repository lives.
Each bounded context has its own repository interface and its own repository implementation. The implementation queries only the columns the context needs and maps them to the context's domain entity:
// In the auth bounded context — domain/repositories/auth_user_repository.dart
abstract class AuthUserRepository {
Future<Either<Failure, AuthUser>> getById(String id);
Future<Either<Failure, AuthUser>> getByEmail(String email);
Future<Either<Failure, Unit>> save(AuthUser user);
}
// In the auth bounded context — data/repositories/auth_user_repository_impl.dart
class AuthUserRepositoryImpl implements AuthUserRepository {
@override
Future<Either<Failure, AuthUser>> getById(String id) async {
try {
final row = await _db.rawQuery(
// Only the 6 columns this context needs — the other 64 don't exist here
'SELECT id, email, password_hash, last_login, mfa_enabled, mfa_secret '
'FROM users WHERE id = ?',
[id],
);
if (row.isEmpty) return Left(NotFoundFailure());
return Right(AuthUser(
id: row.first['id'] as String,
email: row.first['email'] as String,
passwordHash: row.first['password_hash'] as String,
lastLogin: DateTime.parse(row.first['last_login'] as String),
mfaEnabled: (row.first['mfa_enabled'] as int) == 1,
mfaSecret: row.first['mfa_secret'] as String?,
));
} catch (e) {
return Left(DatabaseFailure(e.toString()));
}
}
}// In the chat bounded context — data/repositories/chat_participant_repository_impl.dart
class ChatParticipantRepositoryImpl implements ChatParticipantRepository {
@override
Future<Either<Failure, ChatParticipant>> getById(String userId) async {
try {
final row = await _db.rawQuery(
// Completely different columns from the same table
'SELECT id, username, display_name, avatar_url, is_online '
'FROM users WHERE id = ?',
[userId],
);
if (row.isEmpty) return Left(NotFoundFailure());
return Right(ChatParticipant(
userId: row.first['id'] as String,
username: row.first['username'] as String,
displayName: row.first['display_name'] as String,
avatarUrl: row.first['avatar_url'] as String?,
isOnline: (row.first['is_online'] as int) == 1,
));
} catch (e) {
return Left(DatabaseFailure(e.toString()));
}
}
}The domain model doesn't mirror the database. The domain model reflects what the concept means in this context. The repository is the translator between those two worlds — exactly as described in the repository pattern article.
One Bounded Context, Multiple Aggregates
A bounded context can contain more than one aggregate root. The chat bounded context, for instance:
// Chat context — two aggregate roots
// ChatParticipant: represents a person in a chat
// Conversation: the aggregate root that enforces chat rules
class Conversation {
final String id;
final List<ChatParticipant> participants;
final List<ChatMessage> _messages;
final bool isArchived;
// Enforces the rules of this context
Conversation addMessage(ChatParticipant sender, String content) {
if (isArchived) {
throw DomainException('Cannot send messages to an archived conversation.');
}
if (!participants.any((p) => p.userId == sender.userId)) {
throw DomainException('Sender is not a participant in this conversation.');
}
final message = ChatMessage(
id: _generateId(),
senderId: sender.userId,
content: content,
sentAt: DateTime.now(),
);
return Conversation(
id: id,
participants: participants,
messages: [..._messages, message],
isArchived: isArchived,
);
}
}Conversation is the primary aggregate root of the chat context — the concept the context is named around, the one doing the most rule enforcement. ChatParticipant is a supporting concept within the same context — simpler, fewer responsibilities, but still scoped to the chat domain.
The key: both Conversation and ChatParticipant know nothing about password hashes or shipping addresses. The boundary holds.
Bounded Contexts in Flutter: Your Feature Folders
Here's the thing worth saying explicitly: if you structure a Flutter app with feature folders and keep each feature's domain model separate, you are implementing bounded contexts — whether you've called them that or not.
The folder structure is the boundary made visible:
lib/
├── features/
│ ├── auth/ ← Auth bounded context
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── auth_user.dart
│ │ │ └── repositories/
│ │ │ └── auth_user_repository.dart
│ │ └── data/
│ │ └── repositories/
│ │ └── auth_user_repository_impl.dart
│ │
│ ├── chat/ ← Chat bounded context
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ ├── chat_participant.dart
│ │ │ │ ├── conversation.dart
│ │ │ │ └── chat_message.dart
│ │ │ └── repositories/
│ │ │ └── conversation_repository.dart
│ │ └── data/
│ │ └── repositories/
│ │ └── conversation_repository_impl.dart
│ │
│ └── orders/ ← Orders bounded context
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── customer.dart
│ │ │ └── order.dart
│ │ └── repositories/
│ │ └── order_repository.dart
│ └── data/
│ └── repositories/
│ └── order_repository_impl.dartThe test for whether your boundaries are right: can a developer open features/chat/ and understand the entire chat domain without opening any other feature folder? If yes, the boundary is clean. If they need to read features/auth/ to understand how chat participants work, something has leaked across the boundary.
When Contexts Need to Communicate
Bounded contexts are isolated, but they're not hermetically sealed. The orders context needs to know a customer's shipping address. The chat context needs to know which users exist to add as participants. How do they communicate without coupling?
Shared identifiers, separate models. The orders context doesn't import AuthUser. It has its own Customer entity, which holds a userId string — the same ID that exists in the users table. When it needs customer data, it queries through its own repository using that ID. The identifier crosses the boundary; the model doesn't.
Domain events. When the auth context registers a new user, it emits a UserRegisteredEvent. The chat context listens for this event and creates a ChatParticipant. The orders context listens and creates a Customer record if needed. Neither context knows the other exists — they communicate through events, not direct calls.
// Auth context emits after successful registration
class RegisterUserUseCase {
Future<Either<Failure, AuthUser>> execute(RegisterDto dto) async {
final user = AuthUser.create(dto.email, dto.password);
await _repository.save(user);
// Domain event — other contexts can react without auth knowing about them
_eventBus.emit(UserRegisteredEvent(
userId: user.id,
email: user.email,
registeredAt: DateTime.now(),
));
return Right(user);
}
}
// Chat context reacts — has no idea where the event came from
class ChatParticipantCreationHandler {
Future<void> handle(UserRegisteredEvent event) async {
final participant = ChatParticipant.createFromRegistration(
userId: event.userId,
);
await _repository.save(participant);
}
}This is the asynchronous cross-boundary communication mentioned in the aggregates article. Each aggregate stays in charge of its own consistency. Changes propagate through events, not direct mutation.
The Connection to Aggregates
Bounded contexts and aggregates answer different questions. Bounded contexts answer: what belongs to this domain area? Aggregates answer: who enforces the rules within that area?
The orders bounded context defines that Order and Customer belong here. The Order aggregate root enforces the rules: no confirmation without items, no modification after shipment. The Customer entity in this context is simpler — it holds the delivery information the order needs, with no complex rule enforcement of its own.
Neither concept is meaningful without the other. The bounded context without aggregates gives you organised folders with no enforcement. Aggregates without bounded contexts give you rule enforcement at the object level but no protection against features leaking into each other. Together, they form the complete picture: the boundary protects the system at the feature level, the aggregate protects it at the object level.
What a Correct Boundary Feels Like
There's a practical test worth applying when you're unsure whether something belongs in a bounded context or crosses into another.
If a field or behaviour needs to be explained in terms of another context to make sense — if you find yourself saying "the chat user also needs to know the billing address because orders..." — that's not a chat concern, it's an orders concern. The confusion is the signal. Each context should be explainable entirely within its own vocabulary.
Conversely: if you're writing a use case that needs to reach into two different bounded contexts to complete one operation — combining AuthUser fields with Customer fields in a single domain object — you might have drawn the boundary in the wrong place, or you might need a new context that exists specifically at that intersection.
Bounded contexts are not discovered once and fixed permanently. They evolve as the domain is better understood. The first version is a hypothesis. Production teaches you where the real boundaries are.