A funnel builder is a checkout page, a couple of upsell pages, and a thank-you page. That's what it looks like from outside. From inside it's a page editor, a payment layer, an ecommerce integration, an ad-conversion pipeline, an email system, a coupon engine, an analytics rollup, an abandoned-cart recovery job, a permissions model, and an AI media generator.
Ten things that share nothing except a database. I split them into 16 bounded contexts, and I want to lay out what that bought and what it cost, because "we did DDD" is not information.
The rule, and whether it actually holds
One rule: contexts do not import each other. They talk through ports defined in a contracts layer, and a composition root wires the implementations together.
Rules like that are worth exactly as much as their enforcement, so before writing this I measured. Across all 16 contexts:
- 14 have zero references to another context.
-
messaginghas one: a type-only import of an identity port interface, erased at compile time. -
order-fulfillmenthas one: a reference in a test file, not in shipped code.
Zero runtime cross-context imports. I'll take it, and I'll note that I got there by measuring rather than by trusting, and that the measurement is one shell pipeline that anyone can rerun. A rule you can check in five seconds is a rule that survives; a rule in a README is a preference.
The shape inside each context is boring on purpose: domain/ for entities and value objects, application/ for use cases and ports, infra/ for the adapters that implement them. 395 non-test files across the contexts, 52 in the composition root.
What it actually bought
Swapping an ecommerce backend is a file, not a project. Shopify, WooCommerce and a self-hosted alternative all live behind one commerce-gateway context. The rest of the system asks for a catalog and hands over a completed sale; it has never heard of GraphQL or of X-Shopify-Access-Token. When I added the third backend, nothing outside that context changed.
A payment provider's weirdness stays local. PayPal's authorize-then-capture flow and Stripe's charge-again flow are genuinely different shapes. Inside payments, that's two adapters. Outside it, it's one port. The alternative, which I've seen and written, is if (provider === 'paypal') distributed across order code, email code and analytics code.
Tests stopped needing a database. A use case that takes ports as constructor arguments is tested with plain objects. That was not the goal and it's the benefit I'd actually cite first now.
What it cost, in specifics
The composition root is a real place with real weight. 52 files whose entire job is constructing other things. make-payments.ts, make-commerce-gateway.ts, make-messaging.ts. When a use case needs a new dependency, you edit the factory too. That's a permanent tax on every change, and anyone who says the indirection is free is selling something.
Cross-context workflows need a home, and it isn't obvious. "Buyer accepts an upsell" touches checkout, payments, orders and the ecommerce gateway. It can't live in any one context without breaking the rule. So it lives in the composition layer as a coordinator, and those coordinators are the least principled files in the codebase, because they're where the rule stops helping. If someone reviewed this architecture I'd point them there first.
Naming is a recurring argument. Does a discount code belong to coupons or to storefront-checkout? Does a shipped-order email belong to order-fulfillment or messaging? Every one of those has a defensible answer either way, and the discussion recurs every time a feature straddles the line. That's the real ongoing cost, and it's paid in attention, not in code.
The boundary that mattered most
Not one of the 16. The decision I'd defend hardest is what the system refuses to own: product catalog and inventory.
A funnel builder needs products. The tempting move is to store them, because then you control the schema. The problem is that the merchant's real inventory lives in their store, and the moment you keep a copy, you own a sync problem forever, with a resolution policy for every conflict, and the failure mode is overselling a product you no longer have.
So the catalog is read through the gateway at request time, and completed sales are written back. The system owns the sale record, the funnel, and the customer's path through it. It does not own the merchandise. That single "no" removed an entire category of bug that would otherwise have needed its own context and its own reconciliation job.
The most valuable architectural decisions I've made in this project have been about what not to store. A boundary that keeps state out is worth more than one that organizes it.
When 16 contexts is the wrong answer
If your app is one workflow with one integration, this is overhead with a vocabulary. The structure pays off under two conditions, and I'd want both before recommending it:
- Multiple interchangeable external providers in the same slot. Three ecommerce backends, two payment providers, four ad platforms, three email senders. Ports and adapters are the correct tool for that shape and there isn't a better one.
- Genuinely unrelated subsystems in one deployment. The AI media generator and the coupon engine have nothing to say to each other. Enforced separation is what keeps that true a year later.
If you have one provider per slot and one coherent subsystem, a well-organized services/ directory will beat this, and it'll beat it on the metric that matters, which is how long it takes someone new to find the code that runs.
Top comments (0)