DDD tells you who owns what. FP tells you how to compose it safely. Event-driven architecture tells you how change moves through the system.
The Problem They Solve Together
Most systems degrade the same way. Domain concepts blur into service soup. Side effects hide inside business logic. Temporal coupling locks domains together so tightly that changing one breaks three.
These three patterns attack different axes of that entropy:
| Pattern | Solves | Core Question |
|---|---|---|
| DDD | Semantic chaos | Who owns this concept? |
| FP | Effect chaos | What does this computation need, produce, and fail with? |
| EDA | Temporal coupling | How does change propagate without synchronous locks? |
They are not competing frameworks. They are complementary layers of the same architecture.
The Combined Mental Model
Inside a bounded context, you use DDD + FP. Between bounded contexts, you use integration events.
The flow looks like this:
command → domain logic → state change → domain event → outbox → bus → subscribers → local reactions
Each domain owns its language, its invariants, its state transitions, and its domain events. Effect gives you deterministic composition around each of those steps. Events give you loose coupling between them.
Pure Domain Decisions
The core FP contribution to DDD is this: domain logic should be a pure function.
(State, Command) → Either<Error, (State', Events)>
Given current state and a command, either fail with a domain error or produce the next state plus emitted events. No database. No queue. No storage. Just a deterministic decision.
const decideScheduleCampaign = (
state: CampaignState,
command: ScheduleCampaign,
): Either.Either<DomainError, Decision> => {
if (state.status === "published") {
return Either.left({ type: "campaign.already-published" });
}
return Either.right({
nextState: { ...state, status: "scheduled" },
events: [{
type: "campaign.scheduled.v1",
campaignId: command.campaignId,
scheduledAt: command.scheduledAt,
}],
});
};
This function is testable without mocks, deterministic without discipline, and domain-centered without ceremony. That is FP helping DDD.
Effectful Orchestration Wraps the Decision
The application layer takes the pure decision and runs it in the real world.
const scheduleCampaign = (input: ScheduleCampaignInput) =>
Effect.gen(function* () {
const repo = yield* CampaignRepository;
const outbox = yield* OutboxPort;
const state = yield* repo.getById(input.campaignId);
const decision = decideScheduleCampaign(state, input);
if (Either.isLeft(decision)) {
return yield* Effect.fail(decision.left);
}
yield* repo.save(decision.right.nextState);
yield* outbox.appendMany(decision.right.events);
});
The Effect<A, E, R> triple makes every computation’s contract visible. Dependencies are declared in R, not hidden behind imports. Errors are typed in E, not thrown into the void. That is cognitive compression applied to domain orchestration.
Events Propagate Facts, Not Commands
Once the outbox publishes campaign.scheduled.v1, other domains can react without knowing the publisher’s internals.
The rendering domain sees the event and creates render jobs. The notifications domain sends a confirmation. The analytics domain records activity. Each subscriber receives a fact, not a method call.
Commands are imperative: “do this.” Events are declarative: “this happened.” In FP + DDD, commands go into the domain decision function. Events come out. That separation keeps the architecture honest.
Consumer Autonomy
The consuming domain should not become a puppet of the publisher’s vocabulary. There are three levels of coupling between event publisher and consumer:
Level 1: Direct import. Consumer imports the publisher’s event type. Fast, simple, fine for early-stage modular monoliths.
Level 2: Integration contract. Publisher maps internal events to a stable versioned contract. Better for durable systems with multiple consumers.
Level 3: Local translation. Consumer treats external events as foreign input, translates them into local commands at the boundary. Strongest separation. Best for domains you may later extract.
The practical rule: start at Level 1. Promote to Level 2 when an event has multiple consumers or crosses a bus. Use Level 3 selectively on strategic seams.
Ports Belong to the Consumer
Events are published facts, so sharing them across domains is natural. Ports are required capabilities, so they should be defined by the requiring side.
If campaigns needs media metadata, campaigns defines:
interface CampaignMediaLookupPort {
getRenderableAssetById: (
assetId: AssetId
) => Effect.Effect<Option<RenderableAsset>>
}
That port belongs to campaigns because it reflects what campaigns needs, not what media wants to expose. Do not create a giant shared MediaServicePort that every domain imports. That is how coupling spreads behind the illusion of abstraction.
The Import Boundary Rule
A clean modular monolith enforces this:
Allowed cross-domain: @core/*/events/*
@core/*/integration/*
@core/shared-kernel/*
Disallowed cross-domain: @core/*/domain/*
@core/*/application/*
@core/*/ports/*
Only event contracts can be shared. Ports stay local. Domain internals stay private. Lint this. If you do not enforce it mechanically, it will erode within weeks.
The Outbox Pattern
The classic distributed systems problem: what if you commit DB state but fail to publish the event?
The outbox solves it. Write the event to an outbox table in the same transaction as the state change. A worker reads the outbox and publishes to the bus. Now persistence and event emission are atomic.
For cleanup and lifecycle operations, the same principle applies. Emit a domain event like renderArtifactExpired. A worker handles the event, looks up object keys from the database, and performs the actual deletion. The event expresses intent. The worker executes it.
When to Use Events vs Ports
Use an event when async is acceptable, eventual consistency is fine, and the semantics are “something happened.” Example: CustomerDeleted triggers asset cleanup.
Use a port when you need an immediate answer and consistency must happen in one flow. Example: campaign creation validates that a media asset exists right now.
Events for propagation. Ports for synchronous dependency. They solve different problems.
The Anti-Patterns
Effects inside domain logic. If your pure domain function calls repos and queues directly, you lose the FP benefit. Keep decisions pure. Keep effects in application services.
Events as aggregate dumps. Keep event payloads small and identity-heavy. Carry enough data to route and react, not enough to rehydrate the publisher’s internals.
Shared port packages. A giant CustomerServicePort imported by five domains is a hidden monolith. Consumer-shaped ports prevent this.
Event-driven everything. Not every internal interaction should be async. Use synchronous calls when strong consistency matters. Events are for cross-domain propagation, not intra-domain orchestration.
The Payoff
DDD gives you meaning. You stop semantic chaos by placing concepts inside boundaries that own their language and invariants.
FP gives you control. You stop effect chaos by making every computation’s dependencies, errors, and outputs explicit and composable.
Event-driven architecture gives you flow. You stop temporal coupling by propagating facts instead of making synchronous calls across boundaries.
Together, they produce systems where domain logic is testable without infrastructure, boundaries are enforceable without discipline alone, and change propagates without brittle orchestration.
The bet: as system complexity rises, the value of typed boundaries, pure decisions, and event-driven propagation rises with it.
References
- Effect Website – The missing standard library for TypeScript
- Domain-Driven Design Reference – Eric Evans’ DDD reference
- Martin Fowler on Event Sourcing

