Feb 12, 2025·8 min read

DDD-lite architecture with clear domain boundaries

DDD-lite architecture keeps modules small, names plain, and app services thin. Learn how to split code by domain without heavy rules.

DDD-lite architecture with clear domain boundaries

Why boundaries get blurry

Most codebases do not fall apart because of one big mistake. They drift. A team ships one urgent feature, then another, and the fastest path is to reuse whatever already works.

That is how a billing change ends up reading user tables directly because the data is "already there." A support screen starts calling payment classes because nobody wants to duplicate one rule. It saves time today, then costs far more next month.

Shared convenience gets expensive fast. Once features reach into each other's tables and classes, every change drags hidden dependencies with it. People stop thinking in business areas and start thinking in shortcuts.

The same thing happens with service classes. A file like OrderService or AppService starts small, then absorbs every new rule. Refunds go there. Renewal checks go there. Notification logic goes there too. Each rule change makes that class bigger, harder to read, and harder to trust.

Then the language starts to slip. A term like customer begins with one meaning, then quietly picks up another. In one part of the app, it means the company that pays. Somewhere else, it means an end user with a login. Both meanings seem close enough, so nobody stops to split them. Later, people read the same word and picture different things.

That confusion spreads into the code. One method updates a "customer" record and breaks a report. Another check blocks a refund because it reads the wrong status field. Small edits start breaking parts of the app you did not touch. That is usually the first clear sign that your boundaries are weak.

This is where DDD-lite helps. It does not ask for heavy process or a stack of patterns. It asks for a simpler habit: keep business rules near the business concept they belong to, and stop letting every feature borrow from every other feature.

Fast-moving teams need this even more. In a small SaaS product, the same developer often touches billing, admin tools, and user flows in one afternoon. Without firm module edges and plain words, the app turns into one large shared room where every change bumps into something else.

What DDD-lite means in practice

Start with a small number of modules that match the way the business already talks. If the team says "billing," "accounts," and "subscriptions" every day, those names belong in the code too. You do not need ten layers or a full DDD playbook to get the benefit.

A good DDD-lite structure looks boring on purpose. Each module owns its data, its rules, and the small set of actions people care about. That keeps boundaries visible without turning the codebase into a diagram exercise.

For many products, that means modules like accounts, billing, subscriptions, and notifications. The exact number matters less than the reason for the split. Each module should map to a real business area, not a technical bucket like "services" or "helpers."

Keep rules close to the data they govern. If a subscription can pause only under certain conditions, that rule should live with the subscription model, not in a distant utility file. When rules and data stay together, developers can read one place and understand what the system allows.

Thin application services help a lot. They should coordinate work, not make business decisions. A thin service can load an account, call a domain method like pause(), save the result, and publish an event if needed. Once application services start deciding discounts, grace periods, or refund limits, the boundaries fade fast.

Skip layers that only rename the same action three times. If a controller calls a use case, which calls a manager, which calls a processor, but only one line in each file does real work, that structure adds noise. Keep the parts that clarify responsibility. Cut the ones that only make the path longer.

Plain language matters more than people think. If your support team says "trial ended," write that term in the model. If finance says "credit note," do not hide it behind names like AdjustmentEntity. Clear names reduce mistakes, speed up onboarding, and make code reviews much less tiring.

Pick modules from business language

Good module names usually come from the words people already use in tickets, sales calls, and support chats. If customers say "refund," "invoice," "seat," "trial," and "cancellation," those words matter more than folders like controllers, services, and utils. This approach gets easier when the code sounds like the business.

Start with raw language, not diagrams. Open a week or two of real tickets and write down repeated nouns and actions. You are looking for the terms people use when money moves, access changes, or support steps in.

Words like account, subscription, invoice, refund, and seat often point to module boundaries. So do actions like renew, cancel, charge, refund, and upgrade. Billing can own charges, invoices, and refunds. Orders can own checkout and purchase rules. Support can own ticket workflows and customer notes. That is usually cleaner than splitting everything into api, db, hooks, and helpers, because framework folders mix unrelated business rules together.

Keep the first cut small. Three to six modules is enough for most teams. Add more than that and people start arguing about borders instead of writing clear code. In a modular monolith, a few well-named modules usually beat twenty tiny ones with fuzzy ownership.

A small SaaS product makes this easy to see. If a customer upgrades, gets charged twice, and asks for money back, that flow should not bounce between random service files. Billing should handle the charge and refund rules. Support can record the request and status. Orders might not be involved at all. That split gives you clearer boundaries and makes bugs easier to trace.

Be careful with shared code. Teams often move code into common or shared too early, then every module starts depending on the same bucket. Leave some duplication alone at first. Move code only when repetition starts to waste real time, cause bugs, or force the same rule change in several places.

A simple test works well: if a non-technical teammate can hear a module name and explain what it owns, the name is probably good. If they cannot, the module likely follows the framework, not the business.

How to keep application services thin

An application service should read like a short script. It takes a request, loads the domain objects it needs, asks the domain model to do the work, saves the change, and returns a result that makes sense to the caller.

That sounds almost boring, and that is the point. If the service stays small, your boundaries stay easy to see.

A thin service usually does four things: accept a command such as "approve invoice" or "cancel subscription," load the right objects from storage, call domain methods that hold the business rule, and save the updated state. Then it returns something clear, such as "approved," "rejected," or "already canceled."

Trouble starts when the service begins to think for itself. If it calculates discounts, checks refund windows, compares plan rules, or decides which state changes are allowed, business logic has leaked out of the domain model.

That logic belongs closer to the model, or in a small policy object the model uses. The service can pass in the date, user role, or payment facts, but it should not turn into a pile of if statements.

A good test is simple: can you read the service top to bottom in a few seconds? If yes, it is probably thin enough. If you need to scroll through branches, loops, and helper calls to understand one business action, the service is doing too much.

For example, a subscription cancellation service might load the subscription and billing policy, call subscription.cancel(today, policy), save the subscription, and return the result. The rule about whether the customer gets a partial refund should live inside that domain behavior, not in the service method.

This style makes change safer. When the refund rule changes, you update the domain code in one place. The application service still does the same small job: accept, load, call, save, return.

Clear results help too. If the service returns "past refund window" instead of ERR_42, other developers know what happened without digging through the code.

Name the model in plain language

Clean Up A Modular Monolith
Find where modules leak into each other and put one module back in charge.

Most naming problems start when teams name code after technical steps instead of business facts. The model reads better when a class or method says what happened in the business, not how the code got there.

Refund is clearer than RefundProcessorFactory. The first name points to something people in support, finance, and product already understand. The second describes plumbing. Plumbing has a place, but it should stay at the edges of the code, not in the center.

The same rule applies to behavior. If an order can be canceled, Order.cancel() says it plainly. A name like doOrderCancellationFlow() sounds like a script, not a business action. It also suggests that the real rule lives somewhere else.

Keep one meaning for each term. If "order" sometimes means a draft cart, sometimes a paid purchase, and sometimes a shipment request, the code will drift fast. Pick one meaning and use it in classes, methods, events, and fields. When the business really has two concepts, give them two names.

Generic helpers often create the worst mess. Names like process(), handle(), manager, or utils hide the reason the code exists. When you rename them to match real intent, weak boundaries become easier to spot.

A few simple rewrites show the difference:

  • processRefund() becomes refund.approve() or refund.reject()
  • OrderManager becomes OrderCancellationPolicy if it checks whether canceling is allowed
  • DataHelper becomes RefundReasonParser if it parses customer input
  • execute() becomes sendRefundReceipt() if that is the actual job

This is not just style. Clear names lower the cost of change. A new developer can read the model and understand the business faster. A founder or product manager can scan method names and notice nonsense before it spreads. When the language is plain, thin application services stay thin because the model carries the meaning itself.

A simple example: refunds in a small SaaS product

A customer buys a monthly plan, uses one export, and asks for a refund a week later. Small SaaS teams often route that message through support first, so people start to mix jobs. Support adds notes, billing checks dates, orders checks usage, and soon one messy function does all of it.

In a DDD-lite structure, Support only opens the request. It records who asked, why they asked, and any details that may help later. Support does not decide policy. A support agent can say, "Customer requested a refund after one export," but the agent should not decide whether that request fits the rules.

Orders owns the purchase facts. It knows what the customer bought, when access started, whether the plan is active, and what the customer already used. Billing owns payment rules. It knows whether the refund window is still open for that charge and whether the payment can still be refunded.

The application service coordinates the flow. It asks Orders for a purchase summary. Then it sends the payment data and order facts to Billing for a decision. If Billing approves the refund, the application service tells Billing to issue it, tells Orders to mark the order as refunded, and returns the outcome to Support.

The outcome can stay plain: approved, rejected because the refund window closed, rejected because it was already refunded, or needs review.

That split keeps each module honest. Orders answers facts about the purchase. Billing answers policy questions about money. Support handles the conversation with the customer. The application service moves the request from one step to the next, but it does not hide business rules inside orchestration code.

This also makes change easier. If the company extends the refund window from 14 days to 30, Billing changes. If Support adds a new form field, Support changes. If the team starts tracking a new kind of product usage, Orders changes. One refund request stays one clear flow instead of turning into a pile of mixed concerns.

Mistakes that blur boundaries again

Get Fractional CTO Support
Use senior architecture help without hiring a full time CTO.

Boundaries usually blur through small shortcuts. Each one feels harmless. A month later, the code stops matching the business, and simple changes start touching five files instead of one.

One common problem is the shared utils module that grows into the real domain. It starts with date helpers and string formatting. Then someone adds price rules, refund checks, subscription status logic, and email timing. Now the most business-heavy code lives in the least clear place. Nobody knows which module really owns the rule.

Another problem starts when services reach into each other's private queries. Billing asks Accounts for a raw database-shaped answer. Then support code calls billing internals the same way. After that, every module knows too much about every other module's tables, flags, and edge cases. A boundary still exists in the folder tree, but not in practice.

Controllers can also do real damage when request code starts collecting business rules. A controller checks permissions, refund windows, coupon limits, plan rules, and account status before it calls the application service. That feels fast at first. Then a second endpoint needs the same behavior, and people copy the logic with small differences. Bugs show up because the rule now lives in the web layer instead of the domain.

A subtler mistake is ownership drift. One module stores the data, but another module keeps explaining what that data means. For example, Billing owns invoices, yet Support contains the real logic for refund eligibility because support needed it first. Billing becomes a storage bucket, and Support becomes the place where business meaning lives. That split confuses everyone.

You can usually spot boundary drift when one rule change forces edits in several modules, modules ask for raw fields instead of clear business answers, controllers repeat checks that should live deeper in the code, or teammates cannot agree on which module owns a decision.

Quick checks before you add more structure

Boost Your Technical Team
Give your team a practical structure for faster changes and fewer surprises.

Extra layers feel safe. They also hide simple code under names that sound serious but do very little. Before you add another service, folder, or pattern, check whether the current shape already tells the truth about the business.

A decent DDD-lite setup passes a few boring tests. Boring is good. If the answers are fuzzy, more structure will usually make the fuzz harder to see.

A new developer should find the right module in under a minute. If they have to guess between core, shared, common, and utils, your names are too generic. Billing, Refunds, or Subscriptions is easier to read and harder to misuse.

Each module should own its terms and its write path. If three modules can change refund status, nobody owns the rule. Pick one home for that behavior and let other parts call into it instead of writing around it.

You should be able to test a business rule without booting half the app. If a simple refund rule needs the web server, database, queue, and auth stack just to run, the code is too tangled. Move the rule closer to plain objects and inputs.

Add a layer only when it solves a real problem you can name. "The pattern says so" is not a reason. "We need one place to enforce refund limits" is.

One quick smell test helps a lot: ask someone new to the codebase where they would add a change. If they pause, open five folders, and still sound unsure, the structure is not carrying its weight.

Another test is even simpler. Change one rule and watch how far the edit spreads. If one small policy change touches controllers, helpers, database code, background jobs, and random utility files, your boundaries are already leaking.

That is usually the moment to stop adding abstractions and start removing them. Fewer modules with clearer ownership beat a neat stack of empty layers. If a business rule has one obvious home and one obvious test, you probably have enough structure for now.

Next steps for a cleaner codebase

A cleaner codebase usually starts with one boring fix, not a rewrite. If your team wants clearer boundaries, pick one workflow that keeps causing confusion and redraw it as three modules. Split it into the part that accepts the request, the part that decides the business rule, and the part that talks to email, billing, or storage.

That small exercise does two things quickly. It shows where your code mixes business decisions with technical work, and it gives the team a shared picture of what belongs where. In DDD-lite, that picture matters more than extra patterns or diagrams.

A practical rule for this week: move one business rule out of a service and into a domain object. Keep it small. If a refund can happen only within 14 days, or a trial can start only once, that rule should live next to the thing it describes. Thin application services should pass data around, call the right objects, and stop there.

Keep the work grounded. Choose one messy use case, not the whole system. Redraw it with three modules and name them in business words. Move one rule into the domain model. Leave integrations, queries, and orchestration in the application layer. Then review the result after one real feature, not after a long debate.

Teams also get stuck on words more often than they admit. If people argue about whether something is an order, booking, subscription, account, or workspace, write a one-page glossary. Keep each term short and plain. When names get sharper, code usually gets simpler too.

A modular monolith gets easier to maintain when the team uses the same words in code, tickets, and meetings. That sounds basic because it is. It also cuts a lot of waste. You spend less time translating between product talk and code talk, and fewer rules end up hidden inside helper classes or giant services.

If you want a second pair of eyes, Oleg Sotnikov on oleg.is offers focused Fractional CTO and startup advisory help for teams cleaning up architecture, module boundaries, and AI-augmented development workflows. This kind of review works best when you bring one workflow, one set of names, and one problem your team already feels every week.