TG
ddd·eventstorming·software architecture·13 min read

DDD in practice with EventStorming: from client intake to the first lawsuit

Understand DDD through a legal domain: domain, subdomains, bounded context, aggregate, business rules, EventStorming and simple TypeScript code.

Ler em português
DDD in practice with EventStorming: from client intake to the first lawsuit

Domain-Driven Design becomes practical when the team starts from the business, talks to domain experts and turns real events into a model and code. In this tutorial, we will model a simple legal case system, from client onboarding to the first lawsuit filed with an initial petition.

The goal is not to create a perfect architecture. The goal is to show the flow: domain, subdomains, EventStorming, bounded context, basic rules, model and a small TypeScript implementation that explains the decision.

What is domain in DDD?

Domain is the business. It is not the domain/ folder, not an application layer and not a set of polished classes. It is the business area the software must understand to solve a real problem.

In our example, the domain is the operation of a law office that receives a client, qualifies the legal demand, creates a case, prepares documents and files the first petition.

The domain has rules, words, conflicts, exceptions and decisions. The people who know this deeply are the domain experts: lawyers, paralegals, legal assistants, intake staff, finance staff, legal operations people and anyone who runs the real workflow.

A subdomain is a coherent part inside the larger domain. It helps the team split the business without starting from databases, screens or microservices.

For a simple legal case system, we can start like this:

TypeSubdomainWhy it exists
Core DomainLegal case managementThis is where the business differentiates: understanding the demand, opening the case, preparing the strategy and following the lawsuit
Supporting DomainClient onboardingCollects data, documents, conflict checks, contract acceptance and power of attorney
Supporting DomainDocuments and petitionsOrganizes templates, evidence, versions and minimum petition validation
Supporting DomainDeadlines and calendarControls deadlines, hearings, court notices and internal tasks
Generic DomainAuthenticationCan use an off-the-shelf solution
Generic DomainFinanceCan integrate with ERP, billing or accounting tools
Generic DomainDigital signatureCan integrate with an external provider

Rule of thumb: build carefully what differentiates the law office. Buy or integrate what is generic, as long as the integration does not break the main flow.

A mental map helps separate the levels:

Domain
└── Law office legal operation
 
Subdomain
└── Legal case management
    └── Bounded context: Legal Case Management
        └── Aggregate: LegalCase
            ├── Value object: CaseArea
            ├── Value object: CourtReceipt
            └── Domain event: LegalCaseOpened
 
Integration
└── Port: CourtFilingGateway
    └── Adapter: CourtPortalAdapter

Read it from top to bottom. Domain is the whole law office business. Subdomain is one slice of that business, such as case management, client intake or petitions. Bounded context is where words like Case, Client, Petition and Filing have precise meaning. Aggregate protects a strong rule. Value object names a small concept. Port describes an external need. Adapter translates that need into a concrete system, such as the court portal.

How do you build the EventStorming board with sticky notes?

EventStorming is a collaborative technique for discovering how the business works through events. The team puts what happened in the past on a wall, ordered by time, and uses colors to separate events, commands, policies, systems and questions.

A simple legend for this case:

Sticky note colorMeaningExample
OrangeDomain event, something that happenedClient registered, Case opened, Petition filed
BlueCommand, something someone asks the system to doRegister client, Open case, File petition
PinkPolicy, reaction ruleIf required documents arrived, release review
Light yellowNeeded informationTax ID, Lawyer bar number, court case number
PurpleExternal systemDigital signature, Court portal
RedHotSpot, risk or open questionWho validates conflict of interest?

Visually, the board can look like this:

EventStorming board with legal onboarding, case opening and petition filing flow

The initial board can look like this:

[Command] Register client
    |
    v
[Event] Client registered
    |
    v
[Policy] Check conflict of interest
    |
    v
[Event] Conflict checked
    |
    v
[Command] Open legal case
    |
    v
[Event] Legal case opened
    |
    v
[Command] Attach initial documents
    |
    v
[Event] Initial documents received
    |
    v
[Policy] If contract and power of attorney were signed, release petition
    |
    v
[Command] Prepare initial petition
    |
    v
[Event] Initial petition prepared
    |
    v
[Command] File petition
    |
    v
[Event] First lawsuit filed

This board is not final documentation. It is a tool that forces the team to talk.

Which basic rules appear in the flow?

The most useful rules are the ones that block wrong decisions. In this example, we can start with simple rules:

RuleReason
A client needs name, document and contact data before becoming a party in a caseThe case must identify who will be represented
A case cannot be opened while conflict of interest is pendingThe law office cannot act against a current client or ethical restriction
The initial petition requires at least one party, a responsible lawyer, an initial legal thesis and required documentsWithout that, filing is weak
The petition cannot be filed before the power of attorney is signedThe lawyer needs authority to represent the client
The first lawsuit is only considered filed after the court returns a number or receiptThe event depends on external confirmation

Notice that the rules come from the business. Code should express these rules only after they appear in the conversation with experts.

Which bounded context should we create first?

A bounded context is the boundary where a model and a language are valid. The word Case may mean one thing in intake, another in legal work and another in finance. The context limits that meaning.

For a minimum viable product, I would start with three contexts:

Bounded contextResponsibility
Client IntakeReceive client data, documents and consent
Legal Case ManagementOpen the case, define legal area, responsible lawyer, parties and case status
Petition FilingPrepare the initial petition, validate prerequisites and file it with the court

The first code slice can live inside Legal Case Management, with ports to fetch the client, save the case and call petition filing. It does not need to start as a microservice. The boundary can start as modules inside the same monolith.

How do you turn events into a model?

Now we move from the problem space to the solution space. Do not copy every sticky note into classes. Look for decisions that must be protected.

In our flow, LegalCase looks like a good aggregate because it owns important invariants: a case has a client, legal area, responsible lawyer, status and rules that allow or block the first petition filing.

An initial model:

ElementExample in our domain
AggregateLegalCase
EntityClient, Lawyer, Petition
Value ObjectDocumentNumber, CaseArea, CourtReceipt
Domain EventClientOnboarded, LegalCaseOpened, InitialPetitionFiled
RepositoryLegalCaseRepository
Application ServiceOpenLegalCaseService, FileInitialPetitionService
PortCourtFilingGateway
AdapterHTTP integration with the court portal

The point is to keep high cohesion: case rules stay close to the case. External integrations stay outside the domain core.

What would the TypeScript code look like?

Start small. This example uses simple classes to show intent, not to defend a framework.

type CaseStatus = "draft" | "open" | "petition_filed";
 
type DomainEvent =
  | {
      type: "LegalCaseOpened";
      payload: { caseId: string; clientId: string; area: string };
    }
  | {
      type: "InitialPetitionFiled";
      payload: { caseId: string; receiptNumber: string };
    };
 
class LegalCase {
  private events: DomainEvent[] = [];
 
  private constructor(
    public readonly id: string,
    public readonly clientId: string,
    public readonly area: string,
    public readonly responsibleLawyerId: string,
    private status: CaseStatus,
    private requiredDocumentsReceived: boolean,
    private powerOfAttorneySigned: boolean,
  ) {}
 
  static open(input: {
    id: string;
    clientId: string;
    area: string;
    responsibleLawyerId: string;
    conflictChecked: boolean;
  }) {
    if (!input.conflictChecked) {
      throw new Error("Conflict check is required before opening a legal case.");
    }
 
    const legalCase = new LegalCase(
      input.id,
      input.clientId,
      input.area,
      input.responsibleLawyerId,
      "open",
      false,
      false,
    );
 
    legalCase.record({
      type: "LegalCaseOpened",
      payload: {
        caseId: input.id,
        clientId: input.clientId,
        area: input.area,
      },
    });
 
    return legalCase;
  }
 
  receiveRequiredDocuments() {
    this.requiredDocumentsReceived = true;
  }
 
  signPowerOfAttorney() {
    this.powerOfAttorneySigned = true;
  }
 
  fileInitialPetition(receiptNumber: string) {
    if (this.status !== "open") {
      throw new Error("Only an open legal case can receive an initial petition.");
    }
 
    if (!this.requiredDocumentsReceived) {
      throw new Error("Required documents must be received before filing.");
    }
 
    if (!this.powerOfAttorneySigned) {
      throw new Error("Power of attorney must be signed before filing.");
    }
 
    this.status = "petition_filed";
    this.record({
      type: "InitialPetitionFiled",
      payload: { caseId: this.id, receiptNumber },
    });
  }
 
  pullEvents() {
    const events = [...this.events];
    this.events = [];
    return events;
  }
 
  private record(event: DomainEvent) {
    this.events.push(event);
  }
}

The aggregate protects the rules. It knows nothing about HTTP, databases, queues, court portals or web frameworks.

How do you connect domain code to application services and adapters?

The application service orchestrates the use case. It fetches data, calls the domain, persists the result and talks to external ports.

interface LegalCaseRepository {
  save(legalCase: LegalCase): Promise<void>;
}
 
interface CourtFilingGateway {
  fileInitialPetition(input: {
    caseId: string;
    clientId: string;
    lawyerId: string;
  }): Promise<{ receiptNumber: string }>;
}
 
class FileInitialPetitionService {
  constructor(
    private readonly cases: LegalCaseRepository,
    private readonly court: CourtFilingGateway,
  ) {}
 
  async execute(legalCase: LegalCase) {
    const receipt = await this.court.fileInitialPetition({
      caseId: legalCase.id,
      clientId: legalCase.clientId,
      lawyerId: legalCase.responsibleLawyerId,
    });
 
    legalCase.fileInitialPetition(receipt.receiptNumber);
 
    await this.cases.save(legalCase);
 
    return {
      receiptNumber: receipt.receiptNumber,
      events: legalCase.pullEvents(),
    };
  }
}

In hexagonal architecture, CourtFilingGateway is the port. The concrete adapter can use HTTP, automation, a court API or a queue. The domain does not need to know.

How do you know if the model is good enough?

An initial model is good enough when it helps the team make better decisions. Do not chase perfection. Look for clarity.

Use these signals:

SignalMeaning
Domain experts understand the model namesThe language is close to the business
Developers can explain the rules without looking at the UIThe model became expressive
External integrations appear as portsThe domain is not locked to a vendor
HotSpots become explicit questionsRisk became visible
The code blocks invalid statesThe model protects important decisions

If the model needs too much mental translation, go back to the board. If the code accepts any state, go back to the rules.

What is the full flow from start to code?

The simple cycle is this:

  1. Define the domain as the business that must be understood.
  2. List the subdomains and mark core, supporting and generic.
  3. Bring domain experts into an EventStorming session.
  4. Place events on the timeline.
  5. Add commands, policies, information, external systems and HotSpots.
  6. Group events that seem to belong to the same bounded context.
  7. Pick a small use case, such as filing the first petition.
  8. Model aggregate, entities, value objects, events and repositories.
  9. Write the minimum code that protects the rules.
  10. Return to the board when the code shows the model is wrong or incomplete.

TL;DR: DDD starts from the business. In the legal case system, the domain is the law office's legal work, and subdomains split onboarding, case management, documents, deadlines and generic integrations. EventStorming reveals the real flow. The bounded context protects a language. The aggregate protects rules. Code comes after the conversation, not before it.

Written by AI, reviewed by Thiago Marinho

July 18, 2026 · Brazil