Tactical DDD for enterprise onboardingPart 3
Immutability in aggregates and value objects
Design tenant configuration as an aggregate, enforce enterprise invariants in memory, and use truly immutable value objects for residency policy and quota limits.
After the language and boundaries are clear, tactical DDD becomes useful.
Not before.
In enterprise onboarding, the core tactical problem is not “where do I put services?” It is protecting invariants before a bad configuration reaches a shared runtime.
One invalid state can be expensive:
- a tenant gets 500 seats when the global contract bought 100;
- an EU division is provisioned into a US-only residency policy;
- an IP allowlist is mutated after approval;
- a feature override bypasses a contract entitlement;
- a quota change overloads a shared database cluster.
This is where aggregates and value objects earn their keep.
For Northstar Health Group, the obvious risk is seat allocation. Northstar Global bought 100 seats for the first rollout. Northstar US asks for 500 because the implementation team imported every employee from Active Directory. If that request slips through, Finance has a contract problem, Platform has a capacity problem, and Customer Success has a credibility problem.
TenantConfiguration protects enterprise invariants
Aggregate root: TenantConfiguration
TenantConfiguration is the aggregate root for changes to an operational tenant’s configuration.
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 make a business change.
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 not a data bag. It is the gatekeeper for valid configuration.
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 signed security-compliance checklist for HIPAA 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);
The database still has constraints. It should. But the business invariant lives in the model, where it can be named, reviewed, and tested.
Where async data is fetched
Aggregates should not fetch from databases or call services. They should make decisions using facts passed into them.
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,
),
};
}
}
The method name matters. allocateSeats is a business action. setSeats is a data mutation. The first one invites a contract rule. The second one invites a 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_claims_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. Northstar explains the scenario in the article. The unit test should name the invariant so it remains useful when the next enterprise customer hits 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. They do not protect runtime mutation.
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
By enforcing true immutability and centralized validation, business rules stay protected before a database row is written.
That matters in shared-schema enterprise SaaS because one invalid tenant configuration can affect everyone sharing the runtime.
Principles to apply in your own work
- Pick one configuration change that could create financial, compliance, security, or capacity harm.
- Name the aggregate root that should own that change.
- Move validation out of controllers, services, and triggers into aggregate factories or methods.
- Make compliance and quota value objects immutable at runtime.
- Add invariant tests before repository tests.