Most SaaS products don’t collapse because of a missing feature. They struggle because the data model, workflows, and integrations tangle into a knot no one wants to touch. Domain-Driven Design (DDD) gives you language and structure to keep complexity in the right places and out of the wrong ones. This article is a practical walkthrough of DDD for SaaS teams: how to define bounded contexts, design aggregates, and map integrations—plus how to put it into practice with Vibe Coding so you can move from concepts to working software.
Start with conversations, not tables
DDD begins with language. Before you model tables, get the vocabulary right. Sit with your sales lead, support lead, finance lead, and operations lead. Listen for the words they use repeatedly and the rules they defend. That’s ubiquitous language: a shared glossary that turns fuzzy conversations into precise software behavior.
What to capture in your language sessions
- Terms and their meanings (e.g., “Lead”, “Qualified Lead”, “Customer of Record”)
- Key decisions and who makes them (e.g., “Finance can mark an invoice uncollectible”)
- Invariants (rules that must always be true) (e.g., “A plan cannot be deleted if any customer is subscribed”)
- Events people recognize (e.g., “invoice_paid”, “subscription_canceled”)
- Questionable synonyms or overloaded terms (e.g., “account” meaning user vs. tenant)
Write these as simple statements and short scenarios. Avoid implementation talk. The goal is clarity, not code.
Identify core, supporting, and generic domains
Not every part of your product deserves the same attention. DDD classifies problem areas into:
- Core domain: Where your differentiating value lives. This deserves your best people and the most careful modeling.
- Supporting domains: Important capabilities that enable the core but don’t define your market edge.
- Generic domains: Common problems solved similarly everywhere (e.g., auth, email delivery, file storage).
Why it matters: you will spend more design energy (and accept more custom code) in core domains, while you standardize, buy, or reduce complexity in generic ones. If you Vibe Code with a platform like YeshBe, that distinction guides where you iterate deeply on models versus where you compose existing building blocks and integrations quickly.
Draw bounded contexts where language actually changes
A bounded context is a boundary inside which a particular model and its language are consistent. Outside it, words can legitimately mean something else. This is how you stop “account” from breaking your brain.
Signs you need separate bounded contexts
- The same word means different things to different teams (“customer” in Sales vs. “customer” in Billing).
- Invariants constantly fight each other (“Support can extend trials” vs. “Trials have a hard 14-day limit”).
- Change cadence differs—Billing changes monthly, CRM daily.
- Different compliance envelopes (e.g., PII in Support vs. anonymized metrics in Analytics).
Common SaaS contexts: Sales/CRM, Onboarding, Billing, Entitlements, Support, Product/Workflow, Reporting/Analytics. Each should have its own model, API surface, and data ownership. Integrations between them are explicit, not implicit joins across the whole database.
Design aggregates to protect invariants
Aggregates are clusters of domain objects that you load and modify as a unit to enforce invariants. In practice, they’re the boundary of a transaction. Choose aggregates that match consistency needs, not just relational convenience.
Good aggregate design heuristics
- Put invariants inside one aggregate when possible. Example: A Subscription aggregate ensures that plan changes schedule at the next billing cycle and prorations balance to the cent.
- Keep transaction scope small. If you always need to update 10 rows across four tables together, that might be one aggregate. If you sometimes update hundreds, the aggregate is too big.
- Prefer reference by ID across aggregates. Don’t hold object graphs in memory that you can’t keep consistent; use IDs and domain events.
- Use optimistic concurrency for concurrent updates. Store a version number and reject conflicting writes with a friendly retry flow.
Multi-tenant considerations for aggregates
- Every aggregate must be tenant-scoped. Include tenant_id in primary keys or composite keys.
- Avoid cross-tenant joins or shared aggregates. Shared catalogs (e.g., plans) are read-only references inside tenant aggregates.
- Apply row-level authorization in the application layer and, where possible, in the database.
Context maps and integration patterns
Bounded contexts don’t live in isolation. They form a system through a context map, which documents relationships and translation responsibilities.
Useful relationship patterns
- Conformist: One team conforms to another’s model. Fast, but the conforming team inherits upstream quirks.
- Anti-Corruption Layer (ACL): Introduce a translation layer to shield your model. Slower to build, safer for your core domain.
- Open Host Service: A context publishes a clear, stable protocol (e.g., an API or events) for others to use.
- Published Language: Shared schema or event contracts for interop (e.g., well-defined event names and payloads).
In SaaS, Billing often exposes an Open Host Service (invoices, subscriptions, payments), while Product conforms to Entitlements. Use ACLs for third-party integrations so their quirks don’t bleed into your internal model.
Workflows: commands, domain events, and process managers
Workflows connect user intent to aggregate changes and cross-context reactions.
- Commands: What users or systems want done (e.g., ChangePlan, CancelSubscription). They target a specific aggregate.
- Domain events: Facts that happened (e.g., SubscriptionPlanChanged, InvoicePaid). They’re immutable and time-ordered.
- Process managers (or sagas): Orchestrate long-running, multi-aggregate flows (e.g., provisioning after payment succeeds, or downgrading access at the end of a billing period).
Keep commands and events explicit. That clarity avoids “hidden” coupling where side effects surprise future readers.
Persistence: CRUD, events, or both?
You don’t have to choose one forever. Many teams start with CRUD and add events as they scale. What matters is that your aggregate logic is expressed at the domain layer, regardless of persistence strategy.
- CRUD: Simpler operations. Be disciplined in keeping business rules in code, not scattered in triggers.
- Event-sourced aggregates: Full history and easier temporal queries; more work up front and in tooling.
- Hybrid: Use events for critical lifecycles (e.g., subscriptions) and CRUD for simpler contexts (e.g., tags).
Permissions belong to the domain, not just infrastructure
Authorization isn’t only “is user X role Y?” Domain rules often depend on resource state and relationships: “Only Finance can write off an invoice, but anyone can refund a same-day charge under $100.” Put these policies near aggregates. External policy engines or middleware can enforce structural checks, but the business rules live with the business model.
Example: a Subscription Billing context
Let’s model a Billing bounded context found in many B2B SaaS apps. Inside Billing, define aggregates and events that reflect the language and invariants.
Aggregates and invariants
- Plan: Defines price, interval, and features. Invariant: a plan can be archived but not deleted if subscriptions reference it.
- Subscription: Ties a customer to a plan with state machine (trialing, active, past_due, canceled). Invariants: plan changes schedule at period boundary unless immediate change is allowed; proration lines must reconcile to zero; only one active subscription per product per tenant if required by product rules.
- Invoice: Sum of line items. Invariants: total = sum(lines) + tax - discounts; an issued invoice is immutable except for state transitions (voided, paid, uncollectible) by authorized actors.
- Payment: References invoice and processor charge. Invariants: a payment belongs to one invoice; amount <= invoice balance; currency matches invoice currency.
Commands
- CreatePlan, ArchivePlan
- StartSubscription, ChangePlan, CancelSubscription
- IssueInvoice, ApplyCreditNote, MarkInvoiceUncollectible
- RecordPayment, RefundPayment
Domain events
- PlanArchived
- SubscriptionStarted, SubscriptionPlanChangeScheduled, SubscriptionCanceled
- InvoiceIssued, InvoicePaid, InvoiceMarkedUncollectible
- PaymentRecorded, PaymentRefunded
Process managers
- BillingCycleRunner: On schedule, issues invoices for active subscriptions and emits InvoiceIssued.
- ProvisioningOrchestrator: On InvoicePaid, grants entitlements; on SubscriptionCanceled effective date, revokes them.
- DunningFlow: On past_due, triggers reminders and retries per plan’s dunning policy.
Notice how Entitlements likely lives in a separate bounded context with its own model (Feature, Grant, Revocation) and subscribes to Billing’s events via a published language. This prevents tight coupling while preserving a clear contract.
From language to working software with Vibe Coding
If you’re building with an AI-assisted platform like YeshBe, use prompts to express domain intent precisely. YeshBe is a platform for building software: you describe the system you want, then iterate through conversation to refine models, workflows, and interfaces. DDD gives you the vocabulary to ask for the right structures—and to evaluate the code you get back.
Prompts you can try
Define a bounded context named Billing. Inside it, create aggregates Plan, Subscription, Invoice, and Payment with the invariants listed below. Expose commands (CreatePlan, ArchivePlan, StartSubscription, ChangePlan, CancelSubscription, IssueInvoice, MarkInvoiceUncollectible, RecordPayment) and emit domain events. All aggregates are tenant-scoped by tenant_id. Use optimistic concurrency.
Invariants: - Plan: can be archived but not deleted if referenced by subscriptions. - Subscription: only one active per product per tenant; plan changes schedule at period end; prorations balance to zero. - Invoice: totals are derived; issued invoices immutable except state transitions. - Payment: belongs to one invoice; amount <= invoice balance; currency matches invoice.
Create an Entitlements bounded context that subscribes to Billing events (InvoicePaid, SubscriptionCanceled). On InvoicePaid, grant entitlements per plan features; on SubscriptionCanceled effective date, revoke them. Implement an anti-corruption layer so the internal Entitlements model remains Feature and Grant, not Plan or Subscription.
Generate API endpoints for Billing commands and a read-model for subscription summaries by tenant. Include pagination and tenant isolation. Add input validation and helpful error messages when invariants would be violated.
Write property-based tests for the Subscription aggregate: for random proration scenarios, totals always reconcile to the cent; plan changes at period end never overlap.
Because you can continue refining interactively, you can move from broad specifications to precise fixes: “Reject ChangePlan if dunning is active,” or “allow same-day refund under $100 for Support role.”
Integrations: keep other people’s models out of your core
Most SaaS products integrate with payment processors, CRMs, ticketing systems, data warehouses, and messaging tools. Treat each integration as a separate bounded context or as an ACL around the third party. Translate data into your published language before it reaches your core domain.
Concrete examples
- Payment processor: Webhooks map to domain commands or events via an ACL. Example: processor.charge.succeeded becomes RecordPayment(invoice_id, amount, currency).
- CRM: Sales owns Lead and Opportunity; when an Opportunity hits Closed Won, emit CustomerCreated to Onboarding. Onboarding does not reach into the CRM DB; it consumes events.
- Data warehouse: Downstream facts are extracted via a published language (events or API). Don’t let BI schemas back-flow into your operational model.
Read models and projections: optimize for queries
Your write model protects invariants; your read model serves UIs and reports. Don’t fear duplication for reads. Create projections optimized for common queries: “subscription summary by tenant,” “MRR by plan,” “active entitlements by user.”
- Keep projections tenant-scoped and permission-checked.
- Prefer incremental updates from domain events over heavy joins.
- Expire or rebuild projections when schemas change.
Testing: specification by example
DDD pairs well with test styles that read like business stories.
- Given-When-Then: Given a trialing subscription ending today, When ChangePlan(new_plan) is scheduled, Then the new plan is applied tomorrow and entitlements remain unchanged until then.
- Property-based tests: Randomly generate proration scenarios; assert monetary identities and state transitions.
- Contract tests: Between contexts, verify that published event schemas and HTTP APIs remain compatible.
Generate Given-When-Then tests for the Invoice aggregate covering voiding, paying, overpayment rejection, and currency mismatch errors. Include human-readable failure messages tied to domain language.
Evolving your model without burning it down
You’ll discover better boundaries as you grow. That’s normal. Approach refactors incrementally.
- Strangler approach: Wrap old modules with a facade, route new flows to a new bounded context, and retire endpoints gradually.
- Dual-write with safeguards: For a time, publish to both old and new projections. Reconcile and add alerts for drift.
- Event capture: If possible, emit domain events before and after the change to verify behavioral equivalence.
Security and compliance through domain boundaries
Bounded contexts help you localize sensitive data and policy. Keep PII where it’s needed, and publish redacted events for other contexts. For example, Support might see masked billing details and only limited invoice states. Domain boundaries also make it easier to implement audit logging: “who did what, to which aggregate, and when” with tenant scoping.
Common pitfalls and how to avoid them
- Over-modeling early: You don’t need perfect aggregates on day one. Ship a reasonable cut and iterate as invariants become clear.
- Conflating data ownership: If two teams fight over a table, that table probably spans contexts. Split it.
- Hidden coupling in read models: UIs that join across contexts directly create brittle coupling. Prefer projections built from published events.
- Fat process managers: If orchestration turns into a new god-object, you probably need smaller, more focused events or better aggregate invariants.
- Treating permissions as an afterthought: Put role and policy checks next to domain rules; test them as first-class behavior.
A practical DDD checklist for your SaaS
- Write the language: Terms, invariants, events, and scenarios—separately for each major area.
- Sketch bounded contexts: Sales, Billing, Entitlements, Support, etc. Decide what each owns.
- Define aggregates: Keep them small, tenant-scoped, and responsible for invariants. Use optimistic concurrency.
- Map the context relationships: Conformist where speed matters, ACL where safety matters, published language for shared contracts.
- Make commands and events explicit: Name them in domain language. Prefer asynchronous boundaries across contexts.
- Design projections for the UI: Create tenant-aware read models for common screens and reports.
- Codify permissions in the domain: Co-locate policy with behavior; include them in tests.
- Test by behavior: Given-When-Then, properties, and contracts—not just controllers and endpoints.
- Plan for evolution: Strangler patterns, dual-writes, and event-based verification to move without big bangs.
Using YeshBe to iterate on the model and code
Vibe Coding works best when you bring structure to your prompts. In YeshBe, you can:
- Start with a textual domain glossary and ask the system to propose initial bounded contexts and aggregates.
- Generate endpoints and background jobs from your command and event lists.
- Spin up projections for read models and refine them as you test UI flows.
- Stub an ACL around each external integration and then fill in real mappings.
From the following glossary and invariants, propose bounded contexts and a first pass at aggregates with commands and events. Then scaffold a multi-tenant API with auth and per-tenant data isolation. Include tests for aggregate invariants.
Glossary: - Customer: the business entity paying us. - User: a human tied to a customer; can have roles. - Plan: defines pricing & features. - Subscription: connects a customer to a plan with states. - Entitlement: a feature grant derived from billing state. Key invariants: - One active subscription per product per customer. - Issued invoices are immutable except for state transitions. - Refunds under $100 can be initiated by Support within 24 hours.
The point isn’t to generate perfect code. It’s to move quickly from shared language to working software you can critique. When changes are needed, continue the conversation with precise updates: add a state, adjust an invariant, or split a context. Because YeshBe is a platform for building software—not a set of pre-built systems—you remain in control of your model.
When to apply more or less DDD
Use more DDD when:
- The area is core to your differentiation and will change often.
- Rules are complex or legally/commercially sensitive (billing, compliance, healthcare).
- Multiple teams depend on clear contracts (APIs, events).
Use lighter DDD (or even simple CRUD) when:
- The problem is generic and stable (tags, simple catalogs).
- You’re validating a new idea and don’t yet know the rules.
Bringing it together
DDD is not paperwork. It’s a way to keep your SaaS understandable as you grow. If you get the language right, carve clean bounded contexts, design small aggregates that defend invariants, and make integrations explicit, you’ll avoid the big rewrites that swallow roadmaps. Pair that with Vibe Coding in YeshBe to turn ideas into running systems quickly, and you can iterate on behavior—not just code—without losing your grip on complexity.