Domain-Driven Design in TypeScriptPart 3
Protect business rules with aggregates and value objects
Learn how tactical DDD uses an aggregate root, entities, and immutable value objects to keep an enterprise seat-allocation rule true.
Strategic DDD helped us name the enterprise onboarding domain and divide model ownership between bounded contexts. Now we will move inside one of those boundaries and use tactical DDD to protect a specific business rule in code.
In our running example, a workspace asks for 500 seats even though its contract allows 100. The limit exists in a policy document, but the update handler writes the new value directly. The request succeeds, and the system now contains a tenant configuration the business considers impossible.
An invariant is a business rule that must stay true. An aggregate puts every related change through one object that enforces those rules. A value object gives a value such as a quota or residency policy its own validation and meaning instead of passing around a loose number or string.
In this article, we will model TenantConfiguration as an aggregate, reject the 500-seat request
before it reaches Postgres, and make its value objects genuinely immutable. The goal is practical:
put an important rule where every write path has to obey it.
Open one aggregate and show the rule it protects
Open in StudioWhat tactical DDD buys you
Evans introduced entities, value objects, services, aggregates, factories, and repositories as building blocks for a model-driven design. Vaughn Vernon’s Implementing Domain-Driven Design later gave practitioners a more implementation-focused treatment of those patterns. The value of the patterns is not that they make code look like DDD. They matter when they give an important business decision a clear home.
For the seat-allocation example, the tactical benefits are concrete:
| Design problem | Tactical choice | What the team gains |
|---|---|---|
| A tenant and regional division have different roles | Distinct entities and typed identities | Code cannot casually substitute one identity for another |
| A residency policy is defined by its values | Immutable value object | Validation travels with the value and an approved policy cannot drift |
| Several fields must change under one quota rule | Aggregate and aggregate root | Every supported write crosses one consistency boundary |
| The rule needs contract data from storage | Application service plus repository | I/O is coordinated outside the rule, while persistence details stay local |
| Other code needs to know the allocation happened | Domain event | The model records a named business fact instead of hiding it in a callback |
This gives developers a useful path through unfamiliar code. To understand how seats change, start
with allocateSeats, read the invariant beside it, and run its focused tests. You do not have to
search controllers, ORM hooks, and database triggers before you can state the rule. Product and
engineering can also discuss the same operation by name, which shortens the feedback loop when the
business meaning changes.
There is a cost. More domain types require mapping at the HTTP and database boundaries. Aggregate boundaries take judgment, and a boundary that is too large creates contention while one that is too small cannot enforce the rule. Repositories that merely rename every ORM method add indirection without adding meaning. The Aggregate Design Canvas from the DDD Crew is a practical way to discuss those tradeoffs before turning a cluster of classes into doctrine.
Use these patterns when the cost of an invalid business state is higher than the cost of modeling it explicitly. For simple reference data or ordinary CRUD, a transaction script and database constraint may be clearer. Tactical DDD succeeds when it concentrates meaning. It fails when it distributes ceremony.
A tactical pattern earns its place when a developer can find the rule faster and the system can make the wrong state harder to create.
Aggregate root: TenantConfiguration
TenantConfiguration is the aggregate root for changes to an operational tenant’s configuration.
In Evans’s terms, an aggregate treats a group of related domain objects as one unit for data changes,
and outside code refers to that group through its root. Martin Fowler’s short
DDD Aggregate article is a useful visual
introduction to that boundary.
It owns child concepts like:
- feature flag overrides
- IP allowlist ranges
- quota limits
- residency policy
- seat allocation
- provisioning profile.
External code does not mutate those children directly. It asks the aggregate root to perform a
named business operation such as allocateSeats. The root can then check the contract and residency
rules before producing a new valid configuration. This is what makes the list an aggregate rather
than a collection of related TypeScript objects: every change that could break an invariant passes
through the same gatekeeper.
type DomainResult<T> = { ok: true; value: T } | { ok: false; errors: DomainError[] };
class TenantConfiguration {
private constructor(
readonly tenantId: TenantId,
readonly divisionId: RegionalDivisionId,
readonly residencyPolicy: ResidencyPolicy,
readonly quotaLimits: QuotaLimits,
private readonly featureOverrides: ReadonlyArray<FeatureFlagOverride>,
private readonly ipAllowlist: ReadonlyArray<IpRange>,
) {}
static create(input: CreateTenantConfiguration): DomainResult<TenantConfiguration> {
const errors: DomainError[] = [];
if (!input.residencyPolicy.allowsDivision(input.divisionId)) {
errors.push(new ResidencyPolicyViolation(input.divisionId));
}
if (!input.contractQuota.canAllocate(input.quotaLimits)) {
errors.push(new QuotaExceedsContract(input.tenantId));
}
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
value: new TenantConfiguration(
input.tenantId,
input.divisionId,
input.residencyPolicy,
input.quotaLimits,
Object.freeze([...input.featureOverrides]),
Object.freeze([...input.ipAllowlist]),
),
};
}
}
The aggregate is the gatekeeper for valid configuration, not a bag of fields.
Shape validation is not domain validation
HTTP validation answers questions like:
- Is
tenantIdpresent? - Is
requestedSeatsa number? - Is
dataRegionone of the accepted enum values?
Domain validation answers different questions:
- Is this regional division allowed to use this residency policy?
- Does the enterprise contract allow this seat allocation?
- Is the tenant in a lifecycle state where configuration can still change?
- Would this configuration violate the security requirements for regional audit logging, SAML enforcement, or IP allowlisting?
Keep those layers separate. The controller can reject malformed input before the use case runs. The aggregate protects business invariants after the application layer has loaded the facts needed for the decision.
Before: scattered validation
This is the shape that causes drift:
// controller
if (req.body.seats > 500) throw new Error("too many seats");
// service
if (division.region === "EU" && input.dataRegion !== "eu-central") {
throw new Error("bad region");
}
// database trigger
check(memory_limit_mb <= 8192);
Each check may be reasonable. Together they are not a model. Nobody can answer “what makes a tenant configuration valid?” from one place.
After: invariants in the aggregate
The aggregate enforces the rule before persistence:
const result = TenantConfiguration.create({
tenantId,
divisionId,
residencyPolicy,
quotaLimits,
contractQuota,
featureOverrides,
ipAllowlist,
});
if (!result.ok) {
return result;
}
await tenantConfigurationRepository.save(result.value);
Keep database constraints, but put the business invariant where it can be named, reviewed, and tested.
Where async data is fetched
Aggregates should not fetch from databases or call services. A database call can fail, take an unbounded amount of time, or return different data between tests, while a domain decision should be repeatable for the same inputs. The application service therefore loads the current configuration and contract first, then passes those facts into the aggregate. This keeps I/O at the edge and makes the quota rule a plain in-memory decision.
The application service coordinates I/O:
async function allocateTenantSeats(
command: AllocateTenantSeatsCommand,
): Promise<DomainResult<TenantConfiguration>> {
const [configuration, contract] = await Promise.all([
tenantConfigurationRepository.get(command.tenantId),
contractRepository.get(command.contractId),
]);
const requested = SeatAllocation.create(command.requestedSeats);
if (!requested.ok) return requested;
const result = configuration.allocateSeats(requested.value, contract);
if (!result.ok) return result;
await tenantConfigurationRepository.save(result.value);
return result;
}
The aggregate decides. The application service gathers the facts.
That split keeps business rules testable without pretending the domain model can answer questions it has not been given enough information to answer.
Real operation: allocate seats
An aggregate should expose business operations, not setters.
class TenantConfiguration {
allocateSeats(
requested: SeatAllocation,
contract: EnterpriseContract,
): DomainResult<TenantConfiguration> {
if (!contract.canAllocateTo(this.divisionId, requested)) {
return {
ok: false,
errors: [
new QuotaExceedsContract({
tenantId: this.tenantId,
requestedSeats: requested.seats,
contractSeats: contract.remainingSeatsFor(this.divisionId),
}),
],
};
}
return {
ok: true,
value: new TenantConfiguration(
this.tenantId,
this.divisionId,
this.residencyPolicy,
this.quotaLimits.withSeats(requested.seats),
this.featureOverrides,
this.ipAllowlist,
),
};
}
}
allocateSeatsinvites a contract rule.setSeatsinvites a data mutation bug.
Test the invariant
This should be a domain test before it is a repository test:
it("rejects seat allocation above the enterprise contract quota", () => {
const configuration = tenantConfiguration({
tenantId: tenantId("ten_ops_us"),
divisionId: regionalDivisionId("div_us"),
});
const contract = enterpriseContract({ purchasedSeats: 100 });
const requested = SeatAllocation.create(500);
if (!requested.ok) throw new Error("test setup failed");
const result = configuration.allocateSeats(requested.value, contract);
expect(result).toEqual({
ok: false,
errors: [
new QuotaExceedsContract({
tenantId: configuration.tenantId,
requestedSeats: 500,
contractSeats: 100,
}),
],
});
});
The test documents the business rule without making every fixture client-specific. The article supplies the teaching scenario. The unit test names the invariant so it remains useful when another customer encounters the same rule.
Mapping domain errors to HTTP
The domain should not throw BadRequestException or return status codes. The HTTP adapter maps domain errors to transport responses:
function toHttpProblem(error: DomainError): HttpProblem {
switch (error.code) {
case "quota_exceeds_contract":
return {
status: 409,
title: "Quota exceeds contract",
detail: `Requested ${error.requestedSeats} seats, but only ${error.contractSeats} are available.`,
};
case "residency_policy_violation":
return {
status: 422,
title: "Residency policy violation",
detail: "The selected residency policy is not allowed for this regional division.",
};
}
}
Malformed JSON, missing fields, and invalid enum values are request-shape problems. Contract quotas, residency rules, and lifecycle transitions are domain problems. They may both produce 4xx HTTP responses, but they should not live in the same validation layer.
Value objects: true immutability
Branded types protect identity confusion at compile time: TypeScript can stop a TenantId from
being passed where a RegionalDivisionId is required. They do not protect runtime mutation. If a
value object stores a mutable array, code holding that array can still change an already-approved
policy after validation, even though every property is declared readonly.
For value objects with compliance or quota meaning, freeze the instance:
class ResidencyPolicy {
private constructor(
readonly region: DataRegion,
readonly allowedCountries: ReadonlyArray<IsoCountryCode>,
readonly complianceRegime: ComplianceRegime,
) {
Object.freeze(this.allowedCountries);
Object.freeze(this);
}
static create(input: ResidencyPolicyInput): ResidencyPolicy | DomainError {
if (!input.allowedCountries.every((country) => input.region.allows(country))) {
return new InvalidResidencyPolicy(input.region);
}
return new ResidencyPolicy(input.region, [...input.allowedCountries], input.complianceRegime);
}
allowsDivision(divisionId: RegionalDivisionId): boolean {
return this.complianceRegime.allowsDivision(divisionId, this.region);
}
}
QuotaLimits gets the same treatment:
class QuotaLimits {
private constructor(
readonly seats: number,
readonly storageGb: number,
readonly memoryMb: number,
) {
Object.freeze(this);
}
static create(input: QuotaInput): QuotaLimits | DomainError {
if (input.seats < 1 || input.storageGb < 1 || input.memoryMb < 256) {
return new InvalidQuotaLimits();
}
return new QuotaLimits(input.seats, input.storageGb, input.memoryMb);
}
}
The point is not ceremony. The point is that a configuration approved under one policy cannot be mutated halfway through provisioning.
Readonly child collections
If child collections cross the aggregate boundary, they should be read-only:
getFeatureOverrides(): ReadonlyArray<FeatureFlagOverride> {
return this.featureOverrides;
}
Do not expose push, mutable arrays, or raw child entity references. The aggregate root is the consistency boundary.
The takeaway
Put the write behind one aggregate method that can reject it, and freeze residency and quota values so an approved configuration cannot mutate mid-provisioning.
That matters most in shared-schema enterprise SaaS, where one invalid tenant configuration can affect everyone sharing the runtime.
Principles to apply in your own work
- Design an aggregate around a consistency boundary: the rules that must be true together in one transaction.
- Allow state changes only through the aggregate root, using methods named for domain intentions rather than generic setters.
- Enforce invariants when an aggregate is created and on every state transition; do not rely on callers to remember them.
- Model concepts without identity as immutable value objects that validate their own construction and compare by value.
- Keep aggregates small. Refer to other aggregates by identity and coordinate cross-aggregate work with application services or domain events.
- Test aggregate behavior and invariants without persistence before testing repository integration.
Use an aggregate or value object only when it makes a domain rule explicit. Tactical patterns add value by protecting the model, not by appearing everywhere in it.
Source
The capstone turns this rule into a type-checked application service with focused tests.