DDD, FP, and Event-Driven Architecture: Meaning, Control, and Flow

James Phoenix
James Phoenix

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.

Leanpub Book

Read The Meta-Engineer

A practical book on building autonomous AI systems with Claude Code, context engineering, verification loops, and production harnesses.

Continuously updated
Claude Code + agentic systems
View Book

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

Related

Topics
Bounded ContextsDomain Driven DesignEvent Driven ArchitectureFunctional ProgrammingMessage Bus

More Insights

Cover Image for Dependency Chains Gate Your Throughput

Dependency Chains Gate Your Throughput

When your work is serial, agent latency becomes wall-clock latency. No amount of tooling eliminates the wait. The productive move is redirecting your energy, not fighting the constraint.

James Phoenix
James Phoenix
Cover Image for The Sandbox Is a Harness

The Sandbox Is a Harness

When code becomes the interface between users and systems, the sandbox stops being a security primitive. It becomes a harness for intent.

James Phoenix
James Phoenix