Decompose by Subdomain uses Domain-Driven Design (DDD) to split a large business domain into subdomains (Core, Supporting, Generic) and gives each one its own bounded context, its own model, and usually its own service.
Intro
Decompose by Subdomain uses Domain-Driven Design (DDD) to split a large business domain into subdomains (Core, Supporting, Generic) and gives each one its own bounded context, its own model, and usually its own service. Instead of one giant Claim class that every department edits, each context owns the version of “claim” that it actually needs. The result is smaller, clearer models, fewer cross-team collisions, and investment focused where the business differentiates itself.
Running example for all lessons: an insurance claims system (policies, claims intake, adjudication, fraud, payments, notifications).
Why we need this
- Business reason: An insurer competes on how well it adjudicates claims, prices risk and detects fraud, not on how it sends emails. Architecture should let the company spend its best engineers on the differentiating parts and buy or commoditise the rest.
- Language reason: The word “Claim” means different things to different people. Intake sees a form with documents. Adjudication sees coverage rules and reserves. Finance sees a payable with a bank account. Forcing one model to satisfy all of them produces a bloated, contradictory class.
- Technical reason: Service boundaries that follow the domain stay stable for years. Boundaries that follow database tables or technical layers (for example a “DAO service” or “validation service”) change constantly and create chatty, tightly coupled services.
- Team reason: A bounded context gives a team a clear area of ownership and a shared vocabulary (ubiquitous language) with the domain experts.
What problem it solves
Problem (from the topic list): Entangled business domain models (“god classes”) across enterprise boundaries.
In the claims monolith, a single Claim entity has 90 columns and 40 properties. Intake adds SubmittedVia, adjudication adds ReserveAmount and CoverageDecision, fraud adds RiskScore, finance adds PayeeBankAccount. Everyone edits the same class and the same table.
What goes wrong without it:
- A change for the fraud team breaks a screen used by adjudicators.
- Nobody can explain what
Status = 4means, because each department uses the field differently. - Every release needs coordination across five teams.
- Splitting into microservices along the wrong lines (for example one service per table) reproduces the coupling over the network, giving a “distributed monolith”.
- Unit tests need huge object graphs because the god class drags in everything.
When it is needed (and when it is NOT)
Use it when:
- The domain is complex, with rules that domain experts argue about (coverage rules, reserving, fraud scoring).
- Different departments use the same word for different things.
- Several teams must work in parallel on one large business system.
- You are planning a monolith-to-microservices migration and need defensible service boundaries.
Do NOT use it (or use a lighter version) when:
- The application is simple CRUD (an admin lookup table, a small internal tool). A single module with a transaction script is cheaper.
- The team is small (fewer than about 5 developers) and one deployable unit is fine. You can still separate contexts as modules inside a modular monolith without separate deployments.
- Domain experts are unavailable. Subdomain identification without domain input is guesswork.
- You are tempted to model Generic subdomains (authentication, email) as if they were core. Buy or reuse them instead.
How to identify the problem (key signals)
- God class or god table: One entity with dozens of nullable columns, edited by several teams (
Claim,Customer,Order). - Same word, different meaning: In meetings, “policy” or “claim” means something different depending on who is speaking.
- Merge-conflict hotspots: The same 3-4 files show up in most pull requests from different teams.
- Release coupling: A change to fraud rules requires regression testing of payments.
- Ambiguous status fields: Enums or magic numbers with comments like “used by intake only” and “do not touch, used by finance”.
- Service-per-table designs: Services named
ClaimDataServiceorClaimValidationServicethat must call each other on every request. - Business complaints: “It takes months to change a simple rule”, or engineers cannot answer “who owns this rule?”.
Flow Diagram
From the problem space (subdomains) to the solution space (bounded contexts) and how contexts integrate.
flowchart LR subgraph Core["Core subdomains: build in-house"] ADJ["Adjudication"] FR["Fraud Assessment"] end subgraph Supporting["Supporting: build simply"] IN["Claims Intake"] PL["Policy Lookup"] end subgraph Generic["Generic: buy or SaaS"] NT["Notifications"] ID["Identity - Entra ID"] end IN -- "ClaimSubmittedV1" --> ADJ IN -- "ClaimSubmittedV1" --> FR FR -- "FraudScoreCalculatedV1" --> ADJ PL -- "Anti-Corruption Layer" --> ADJ ADJ -- "Decision events" --> NTLevel 1: Beginner
Core concept
DDD classifies the business into subdomains:
| Type | Meaning | Claims example | Strategy |
|---|---|---|---|
| Core | What makes the company win; complex and changes often | Claims adjudication, fraud scoring, pricing | Build in-house with best people |
| Supporting | Needed, specific to the business, not a differentiator | Claims intake and document management, policy admin | Build simply, or lightly customise |
| Generic | Same for every company | Identity, email/SMS, PDF generation, payments gateway | Buy or use SaaS/library |
A bounded context is the boundary inside which one model and one vocabulary are consistent. Subdomains live in the problem space (the business); bounded contexts live in the solution space (the software). Ideally they map roughly one to one.
Analogy
A hospital has emergency, radiology, pharmacy and billing. All of them talk about a “patient”, but each keeps different information and different rules. Nobody tries to build one universal patient file that all departments edit at the same time. They exchange only the information they need.
Minimal example
The same real-world claim is represented differently in two contexts (C#, .NET 10):
// Claims Intake context: focused on capturing what the customer submittednamespace Claims.Intake;
public sealed record SubmittedClaim( Guid ClaimId, string PolicyNumber, DateOnly IncidentDate, string Description, IReadOnlyList<string> DocumentIds);
// Adjudication context: focused on coverage decisions and moneynamespace Claims.Adjudication;
public sealed class AdjudicationCase{ public Guid CaseId { get; } public Guid ClaimId { get; } // reference only, not a shared object public decimal ReserveAmount { get; private set; } public string Decision { get; private set; } = "Pending";
public AdjudicationCase(Guid caseId, Guid claimId) { CaseId = caseId; ClaimId = claimId; }
public void Approve(decimal amount) { if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount)); ReserveAmount = amount; Decision = "Approved"; }}Notice that neither class has properties from the other context. They are linked only by ClaimId.
Level 2: Intermediate
Step 1: Discover subdomains (Event Storming)
Run a workshop with domain experts. Put domain events on a wall (ClaimSubmitted, CoverageVerified, FraudScoreCalculated, PaymentIssued), group them, and find where vocabulary changes. Each group is a candidate bounded context. Record them on a context map.
Resulting map for the claims system:
- Core: Adjudication, Fraud Assessment
- Supporting: Claims Intake, Policy Lookup
- Generic: Notifications, Identity (Microsoft Entra ID), Payments gateway
Step 2: Implement in .NET as separate projects or services
Solution layout (modular, can later become separate deployables):
src/ Claims.Intake/ (Supporting) ASP.NET Core Web API, EF Core -> SQL Server Claims.Adjudication/ (Core) ASP.NET Core Web API, EF Core -> PostgreSQL Claims.Fraud/ (Core) Worker + API Claims.Contracts/ Integration events only (small shared package)Each context owns its data. Cross-context communication uses events or explicit APIs, never a shared table.
// Claims.Contracts: the ONLY thing shared. A stable, versioned integration event.namespace Claims.Contracts;
public sealed record ClaimSubmittedV1( Guid ClaimId, string PolicyNumber, DateOnly IncidentDate, DateTimeOffset SubmittedAt);// Claims.Intake: publishes the event after saving (outbox omitted here, see Day 12)app.MapPost("/claims", async (SubmitClaimRequest req, IntakeDbContext db, IPublishEndpoint bus) =>{ var claim = SubmittedClaim.Create(req.PolicyNumber, req.IncidentDate, req.Description); db.Claims.Add(claim); await db.SaveChangesAsync();
await bus.Publish(new ClaimSubmittedV1( claim.ClaimId, claim.PolicyNumber, claim.IncidentDate, DateTimeOffset.UtcNow));
return Results.Accepted($"/claims/{claim.ClaimId}", new { claim.ClaimId });});IPublishEndpoint here comes from a messaging library such as MassTransit. Any broker abstraction works, and the point is the contract, not the library.
// Claims.Adjudication: consumes the event and builds ITS OWN modelpublic sealed class ClaimSubmittedConsumer(AdjudicationDbContext db) : IConsumer<ClaimSubmittedV1>{ public async Task Consume(ConsumeContext<ClaimSubmittedV1> ctx) { var m = ctx.Message; if (await db.Cases.AnyAsync(c => c.ClaimId == m.ClaimId)) return; // idempotent (Day 17)
db.Cases.Add(new AdjudicationCase(Guid.NewGuid(), m.ClaimId)); await db.SaveChangesAsync(); }}Step 3: Angular side
Model the UI per context as well. A claims-intake feature module and an adjudicator workbench should not share one giant Claim interface.
export interface SubmittedClaim { claimId: string; policyNumber: string; incidentDate: string; description: string;}
// adjudication/models/adjudication-case.tsexport interface AdjudicationCase { caseId: string; claimId: string; reserveAmount: number; decision: 'Pending' | 'Approved' | 'Denied';}Use standalone components and lazy-loaded routes per context (loadChildren / loadComponent) so each context’s UI code is isolated and can later be split further (see Day 53).
Level 3: Advanced
Performance and scalability
- Scale contexts independently: Fraud scoring is CPU-heavy and bursty, Intake is I/O-heavy and spiky at end of day. Separate deployables allow separate scaling rules.
- Avoid runtime cross-context joins. Where a screen needs data from two contexts, use API Composition (Day 10) or a locally cached read model updated from events.
Consistency
- Cross-context workflows are eventually consistent. Use Sagas (Day 7) for multi-step processes such as submit, verify coverage, score fraud, approve, pay.
- Use an Anti-Corruption Layer (ACL) when integrating with a legacy system or a poorly modelled external system, so its concepts do not leak into your model.
// ACL: translate the legacy model into the Adjudication vocabularypublic sealed class LegacyPolicyAdapter(ILegacyPolicyClient legacy) : IPolicyCoverageLookup{ public async Task<Coverage> GetCoverageAsync(string policyNumber, CancellationToken ct) { var dto = await legacy.GetPolAsync(policyNumber, ct); // ugly legacy shape return new Coverage(dto.PolNo, dto.LimAmt, dto.DedAmt, dto.StatCd == "A"); }}Security
- Each context authorises independently. Do not forward a broad “admin” token between contexts. Use service-to-service identities with least privilege (managed identities on Azure).
- Sensitive data (bank accounts, medical details) should live in only the context that needs it.
Failure modes and common mistakes
- Boundaries by technical layer: “UI service”, “business service”, “data service” are not subdomains.
- Boundaries too small (nano-services): One aggregate per service creates chatty calls and distributed transactions. Start with a coarser context and split when there is pain.
- Shared “Common” library with domain entities: This re-creates the god class as a NuGet package. Share only integration contracts and truly generic utilities.
- Skipping domain experts: The result is a technically neat but business-wrong split.
- Treating subdomains as static: Business evolves; a Supporting subdomain can become Core (for example, when customer self-service becomes a differentiator). Revisit the map yearly.
- Ignoring Conway’s Law: If team structure does not match the contexts, the architecture drifts back toward the org chart.
Level 4: Expert and Architect view
Trade-offs and alternatives
| Approach | Boundary driven by | Strengths | Weaknesses | Best fit |
|---|---|---|---|---|
| Decompose by Subdomain (DDD) | Domain model and language | Stable boundaries, aligns Core investment, clear ubiquitous language | Requires domain expert time and DDD skill; upfront analysis | Complex domains with several teams |
| Decompose by Business Capability (Day 1) | What the business does (org-level capabilities) | Simple to explain, maps to departments | Can be coarse; may mix several models in one capability | Top-down enterprise alignment; first-cut split |
| Decompose by technical layer | Tech stack layers | Easy to start | Chatty, coupled, every feature touches every service | Almost never for microservices |
| Modular monolith with bounded contexts | Same as subdomain, single deployable | Low ops cost, easy refactoring, strong boundaries via modules | Shared runtime and release train | Small/medium teams, first step before microservices |
| Nano-services (per aggregate) | Individual aggregates | Fine-grained scaling | Distributed transaction explosion, ops overhead | Rarely justified |
Combines with
- Day 1 Business Capability: capabilities give the first cut, subdomains refine it.
- Day 4 Service per Team: one team per bounded context.
- Day 5 Database per Service: each context owns its data.
- Days 7, 11, 12: Saga, Domain Event and Outbox for cross-context communication.
- Day 49 Strangler Fig: extract contexts one by one from a monolith.
ADR (suitable for architecture review)
ADR-002: Split the claims platform by DDD subdomains
- Status: Proposed
- Context: The claims monolith has a single 90-column
Claimentity edited by Intake, Adjudication, Fraud and Finance teams. Release lead time is 6 weeks, with 30% of production incidents traced to cross-team side effects. - Decision: Identify bounded contexts through Event Storming. Treat Adjudication and Fraud Assessment as Core, Intake and Policy Lookup as Supporting, and Notifications, Identity and Payments Gateway as Generic (bought or SaaS). Implement contexts first as modules in a modular monolith, then extract Core contexts to separate services when independent scaling or release cadence is justified.
- Consequences (positive): Focused investment on Core; clearer ownership; independent models and releases; smaller test scopes.
- Consequences (negative): Eventual consistency between contexts; need for messaging and observability; upfront workshop time; risk of wrong boundaries in the first iteration.
- Alternatives considered: Table-based split (rejected: high coupling); technical-layer split (rejected); keep the monolith (rejected for Core, accepted for Generic modules).
- Review trigger: Reassess boundaries after two quarters using the metrics from section 4 (merge conflicts, release coupling, incident causes).
Azure implementation
Decomposition by subdomain is a design activity, so Azure does not “implement” it directly. Azure hosts, connects and secures the resulting contexts. Verify current SKUs and prices on the Azure pricing pages before committing, as tiers and prices change.
Hosting options per context
| Need | Azure service | Notes |
|---|---|---|
| Web APIs for Supporting contexts | Azure App Service or Azure Container Apps | Container Apps gives scale-to-zero and Dapr/KEDA integration with lower ops than AKS |
| Core contexts with heavy scaling or mesh needs | Azure Kubernetes Service (AKS) | Use when you need full Kubernetes control (see Days 46-48) |
| Bursty background work (fraud scoring) | Azure Functions or Container Apps jobs | Event-driven scaling |
| Cross-context messaging | Azure Service Bus (topics/subscriptions) | Standard tier for topics; Premium for isolation, larger messages and VNet features |
| Data per context | Azure SQL Database, Azure Database for PostgreSQL flexible server | One database per context; do not share |
| Secrets and config | Azure Key Vault, Azure App Configuration | Managed identity access, no secrets in code |
| Edge routing | Azure API Management or Application Gateway / Front Door | See Day 19 |
| Identity (Generic subdomain) | Microsoft Entra ID / Entra External ID | Buy, do not build |
| Monitoring | Application Insights, Azure Monitor, Log Analytics | Correlate across contexts with distributed tracing |
Configuration guidance
- One resource group (or at least one clearly tagged set of resources) per bounded context, tagged with
context,owner-teamandsubdomain-type. - Create a Service Bus namespace with one topic per integration event family (for example
claims.claim-submitted) and one subscription per consuming context. - Give every service a system- or user-assigned managed identity and grant it only the roles it needs (for example
Azure Service Bus Data Receiveron its own subscription). - Deploy one database per context; enforce with Azure Policy or reviews so no context receives credentials to another context’s database.
- Enable Application Insights with OpenTelemetry so traces cross contexts.
Pricing and tier considerations
- Container Apps: consumption plan is pay-per-use with a monthly free grant, and suits Supporting contexts with low or spiky traffic.
- Service Bus: Standard is priced per operation with a base charge; Premium is priced per messaging unit and is the choice for predictable high throughput and network isolation.
- Azure SQL: serverless tier can auto-pause dev/test or low-use contexts; provisioned or Hyperscale for Core contexts with steady load.
- Generic subdomains bought as SaaS or Azure PaaS (Entra, Communication Services for email/SMS) usually cost less than building and maintaining them.
Reference architecture (text)
Clients (Angular SPA on Azure Static Web Apps) call Azure API Management. APIM routes /claims to the Intake service (Container Apps, Azure SQL) and /cases to the Adjudication service (AKS or Container Apps, PostgreSQL). Intake publishes ClaimSubmittedV1 to a Service Bus topic. Adjudication and Fraud subscribe independently. Fraud publishes FraudScoreCalculatedV1 back. Notifications (Azure Communication Services, behind a small adapter) subscribes to decision events. All services authenticate with managed identities, read secrets from Key Vault, and emit telemetry to Application Insights and Log Analytics.
Teaching guide for my team
Explain to a beginner in 2 minutes
“Imagine one giant form that every department in the insurance company has to fill in and edit. Everyone breaks everyone else’s work. Instead, give each department its own form with only what it needs, and let them pass short notes to each other. Some departments are what makes us special (adjudication, fraud), so we put our best people there. Others, like sending emails, every company does the same way, so we just buy that.”
Explain to an intermediate developer in 5 minutes
- Show the god
Claimclass and ask “who owns each column?”. - Introduce the three subdomain types and place the claims features into them.
- Define bounded context and ubiquitous language: the same word can mean different things in different contexts, and that is fine.
- Show two small models (
SubmittedClaim,AdjudicationCase) linked by an ID. - Explain how contexts talk (integration events, ACL) and warn about the shared “Common” library trap.
- Finish with the ADR: start as modules, extract when pain justifies it.
Hands-on exercise
Exercise: Given the list of features (submit claim, upload documents, check coverage, calculate reserve, score fraud, approve/deny, issue payment, send SMS, log in), do the following in 45 minutes:
- Classify each as Core, Supporting or Generic.
- Draw 4-6 bounded contexts and name the ubiquitous-language term for “claim” in each.
- Define one integration event between two contexts.
- Implement two classes in C# (one per context) and one event record.
Expected outcome: Adjudication and Fraud are Core; Intake and Policy Lookup are Supporting; Notifications and Login are Generic. No context class references another context’s class. The only shared type is the versioned event record. Participants can explain why “Payment” is not part of Adjudication’s model.
Interview-style questions
- What is the difference between a subdomain and a bounded context? A subdomain is a part of the business problem (problem space). A bounded context is the boundary of a model in the software (solution space). They often align but need not match one to one.
- Why not share one
Customerclass across all services? Each context needs different attributes and rules for a customer. A shared class couples release cycles and grows into a god class; use per-context models linked by IDs and events. - How do you decide which subdomains to build in-house? Build Core (competitive differentiator), build Supporting simply or customise, and buy or use SaaS for Generic. Re-evaluate periodically because subdomains change type.
Mastery checklist
- I can classify features of a real system into Core, Supporting and Generic and justify each choice.
- I can run or take part in an Event Storming session and derive bounded contexts from it.
- I can show two different models of the same real-world concept in different contexts and explain why.
- I can design cross-context communication with integration events and know when an Anti-Corruption Layer is needed.
- I can explain why table-based and technical-layer splits fail.
- I can recommend a modular monolith first and state the criteria for extracting a service.
- I can map contexts to teams and Azure resources, including one database per context and managed identities.
- I can write an ADR for a decomposition decision with trade-offs and review triggers.
Key takeaway
Split the system along the seams of the business language, not the database or the tech stack. Invest your best engineers in Core subdomains, keep Supporting ones simple, and buy Generic ones.
