Feb 01, 2026·8 min read

Architecture tests for module boundaries in fast backends

Learn how architecture tests for module boundaries stop bad imports, keep modules separate, and protect a backend team moving fast.

Architecture tests for module boundaries in fast backends

Why boundaries break after a few fast releases

Fast teams rarely wreck a backend in one dramatic move. It usually happens through small shortcuts that feel reasonable at the time. A bug needs a fix before lunch, so a developer imports code from another module instead of adding a proper interface. The app works, the ticket closes, and the shortcut stays.

One bad import looks harmless. Then it ties two modules together in a way nobody planned. Order code starts calling payment internals. Shipping reads user data straight from billing. Before long, a change in one place can break something far away, and nobody feels comfortable touching either area.

This gets worse when tight deadlines last for weeks. Teams stop asking, "Should this module know about that one?" and start asking, "Can we ship this today?" That tradeoff feels fine once. After five or ten releases, it becomes normal.

The pattern is familiar. One urgent fix reaches across a boundary. Someone copies the same approach on the next task. A third change depends on that shortcut. Tests cover the behavior, so the coupling starts to look legitimate.

Copy and paste speeds up the damage. If one feature grabs data from the wrong module, the next developer often reuses that code because it already works. The backend starts growing around accidents instead of clear design.

Cleanup gets harder after every release. More code depends on the shortcut. More tests lock in the wrong shape. More people assume the dependency is normal because they see it in several places already. Fixing it no longer feels like a small refactor. It feels risky, expensive, and easy to postpone.

That is why fast teams need architecture tests around module boundaries. They catch the first bad import before it becomes a habit. On a small product team shipping often, that matters a lot. By release ten, one careless dependency can make the whole backend feel fragile.

What these tests actually check

Architecture tests check the shape of the codebase. They do not care whether a feature works for the user. They care whether one part of the backend reaches into places it should not touch.

Most of the time, they read import paths, package names, or namespaces and compare them against a rule file. If the orders module imports billing internals, or an API handler reaches straight into database code, the test fails. That failure is useful because it catches a structural shortcut while it is still small.

Start with a simple question: who may import what? Define allowed paths between modules, then let the test check every new dependency against that map. That removes a lot of manual review work.

These tests should also protect private module code. A module can expose a small public surface and hide the rest. If another developer imports orders/internal/price_rules instead of the public orders API, the test should stop it. That keeps refactors cheaper because private code can change without breaking half the backend.

Dependency direction matters too. In a modular backend, business code should not depend on delivery code like HTTP handlers, and shared packages should not quietly depend on product modules. Once that direction flips, the code gets sticky fast. Small changes start rippling through places that should stay separate.

One rule saves a lot of future arguments: keep exceptions in one visible place. Sometimes you do need a special allowance, such as letting a reporting module read order data directly for speed. Put that exception in the test config, add a short reason, and leave it in plain sight. Hidden exceptions are where clean designs start to decay.

A good test setup makes four things obvious: which modules may depend on each other, which folders are private, which direction dependencies must follow, and which exceptions the team accepts on purpose. That is usually enough to stop a modular backend from turning into a maze of side doors.

Pick the boundaries before you write rules

Good architecture tests start with plain language. Name the parts of the system the way your team already talks about them: orders, billing, auth, notifications, admin. If a module name sounds vague, like "core" or "utils," it usually hides mixed responsibilities.

Each module also needs one public entry point. That can be a package, a service interface, or a small API surface. Outside code should use that entry point and nothing deeper. If another module imports internal repositories, private models, or helper files, the boundary is already weak before you add a single test.

Shared code needs more discipline than most teams expect. Real shared code is boring and stable: logging, IDs, time helpers, database plumbing, maybe a few common types. Business rules are rarely shared. If pricing logic, order validation, or email content moves into a shared folder too early, feature code starts leaking across the backend.

A quick filter helps. Can another module use this code without learning business rules? Would more than one module depend on it for a clear reason? Does it expose a small API instead of a pile of helpers? Would you still place it there if a new team owned the feature?

After that, decide which imports you will never allow. Start with the obvious ones: feature modules importing each other's internals, transport layers reaching into another module's storage code, and anything outside a module importing its internal or private packages. A short list of hard bans works better than a long rule file full of exceptions.

This matters early in a startup backend. Auth can expose user identity through a public interface, and notifications can consume that interface. Notifications should not import auth database models just because it saves ten minutes today. That sort of shortcut tends to survive six releases and turns rule writing into cleanup instead of protection.

Pick the seams first. Then write tests that guard them.

Set up your first rules step by step

Start small. Pick one service, not the whole backend. Orders or billing usually works well because other modules touch them often, so boundary problems show up quickly.

If you try to cover every module on day one, the team spends more time fixing old messes than building a useful safety check. One clean rule in one service beats twenty rules nobody trusts.

Begin by blocking imports into private folders. That gives you a fast win with very little debate. If someone imports from orders/internal or orders/private, the test fails.

That rule matters because private code is where shortcuts often begin. A developer needs one helper, reaches inside another module, and six weeks later the backend feels tangled again.

Next, make the allowed path obvious. Give the module one public entry point, such as a top-level package or a small facade that other modules can call. Then the rule stays simple: outside code may use the public entry point, and nothing else.

A practical rollout is short:

  1. Add one architecture test for one module.
  2. Deny imports into folders marked internal or private.
  3. Allow imports only through the module's public surface.
  4. Run the test in every build.

Do not wait for the whole codebase to become perfect before you enforce it. If old violations already exist, freeze them and block only new ones. That keeps the team moving while the code gets cleaner over time.

The build should fail on every new boundary break. If the rule only prints a warning, people ignore it the first time they are in a hurry. A failing build feels strict, but it saves a lot of repair work after a few fast releases.

Keep the rules next to the code they protect. Put the test in the same repo and near the module, not in a separate corner that nobody opens. When a module changes, the boundary rule can change in the same pull request.

That is enough to get started. You do not need a big rollout plan. You need one module, one public door, and a build that says no when someone tries the side window.

A simple example from an order backend

Clean up shared code
Sort shared packages, private folders, and ownership before coupling gets worse.

A customer places an order, and the backend needs to charge a card and send a confirmation email. That sounds small. It gets messy fast if one module starts reaching into another module's internals.

Keep the split boring and strict. The orders module creates the order, stores its own data, and calls payments through an interface such as PaymentGateway. The payments module handles charging logic, retries, and provider errors. orders should know that a payment was requested, not how the charge works behind the scenes.

The same rule applies in the other direction. payments should never read order tables or import order repositories directly. If payments needs data such as orderId, amount, or customerEmail, orders should pass that data in the request. Another option is an event with the exact fields payments needs. That keeps ownership clear.

Email code should sit outside both modules. A small notifications module can listen for events like OrderPaid and send the message. That way, neither orders nor payments needs SMTP clients, templates, or delivery retries mixed into business logic.

A basic rule set can look like this:

orders -> may import payments.api
orders -> may not import payments.internal
payments -> may not import orders.storage
payments -> may not import orders.repository
orders -> may not import notifications.email
payments -> may not import notifications.email

Now picture a rushed release. A developer adds a refund check and imports orders/repository inside payments because it is quicker than changing the interface. The feature works on their machine. The architecture test fails right away, before the shortcut lands in main.

That failure matters because shortcuts always grow. One direct import turns into two. Then a payment job depends on order schema details, and changing one table breaks both modules.

This is the real win. The test turns a vague design rule into a hard stop. Teams do not need another long review comment about "keeping things clean." The build says no, and the code stays modular.

Where teams usually slip

Most boundary rules do not fail because the tool is difficult. They fail because teams make small "temporary" choices until the map no longer matches the code.

A common mess starts in shared code. A helper looks harmless, so someone drops it into a common folder. Soon that folder holds date formatting, API clients, auth checks, and bits of business logic from several modules. After that, any module can reach into any other module through a side door.

Exceptions cause the next leak. Teams often add them on day one because one import is awkward, one migration is still open, or one test needs special treatment. A few narrow exceptions can be fine. A long allowlist is usually just a quiet way to turn the rule off.

Another mistake is checking package names but not real file paths. A rule may say that orders cannot import billing, yet an alias, wrapper, or odd folder layout still crosses the boundary. The rule looks strict on paper, but the build still pulls in code from the wrong place.

Generated files and test folders create a different problem. Some teams ignore them completely. Then generated clients drag forbidden dependencies into a module, or test utilities start behaving like production code. Other teams do the opposite and run every rule against every folder, which creates noise and false alarms.

The warning signs are usually obvious. The shared folder grows faster than any real module. Rule files collect comments like "skip for now" or "temporary." One exception turns into several more within a few weeks. Test helpers import large parts of the app. Generated code sits in the same paths as hand-written code.

Skipped rules are usually the first thing to fix. Teams leave them in place for months because nothing breaks right away. But a skipped rule tells everyone the boundary is optional. If the rule is wrong, delete it and rewrite it later. If the rule is right, turn it back on and deal with the pain while the change is still small.

That is where architecture tests help most. They do not just catch one bad import. They stop slow drift before a modular backend turns into one big codebase with nicer folder names.

Keep the rules useful as the code grows

Cut costly backend drift
Bring in CTO level guidance before one temporary exception becomes normal.

A rule set that worked in the first few releases can turn into background noise by the tenth. Teams add one exception to ship faster, then another, and soon the test still passes while the design drifts.

Keep failures where developers already pay attention. If import rules fail in a pull request, the author can fix the problem while the change is still small. If the same failure waits for a nightly job, people often postpone it until nobody wants to touch it.

Architecture tests should change at the same pace as the code. When you create a new module, add its allowed imports in the same pull request. That habit saves time later because nobody has to guess what the module was supposed to depend on.

A few habits help. Add a boundary rule when a new module appears. Put a short note on any exception and give it an owner. Remove exceptions as soon as a refactor lands. Recheck dependencies when one module becomes two.

Old exceptions are where most rule sets go bad. A temporary allowlist often stays for months after the team has already cleaned up the code. If a refactor removes the need for an exception, delete the exception in the same change. Treat it like dead code. Leaving it around invites the next shortcut.

Module splits need extra care. A single "user" module might later split into "auth" and "profile." If you keep the old import rules, other parts of the backend may still reach across both modules as if nothing changed. Review the boundaries right away, or the split becomes cosmetic.

This matters even more on small teams that ship fast. One person can keep the rule set healthy with a short review during code review or release prep. If your team wants an outside look, Oleg Sotnikov at oleg.is works with startups and small businesses as a Fractional CTO and advisor, helping clean up architecture decisions before they turn into expensive habits.

If a rule no longer matches how the system works, update it. If an exception no longer has a reason, delete it. A good rule set should feel a little strict and easy to trust.

Quick checks before each release

Fix cross module imports
Find the imports behind recurring review comments and refactor them with a clear plan.

Fast teams do not need a heavy review ritual before every deploy. They need a short gate that catches the same boundary mistakes every time, especially after a busy sprint.

A good release check fits into CI and takes only a few minutes to read when it fails. Check that no module imports files from another module's private folders. If code reaches into internal, private, or feature-specific subfolders, treat it as a broken boundary.

Check the public API of each module too. If other modules depend on too many exports, shrink the surface and keep only the parts they should call. Check shared code ownership as well. Every shared package should have one team or one person who decides what belongs there and what does not.

Review every exception. If you allowed a rule break for a deadline, give it an owner and an end date, or it will stay forever. Then make sure failed architecture tests block merges. A warning people can ignore is not a real guardrail.

Small APIs matter more than teams expect. When a module exposes too much, other parts of the backend start depending on details that should stay local. A month later, one small refactor turns into a risky release.

Shared code needs the same discipline. Teams often drop random helpers into a common folder because it feels faster. It usually creates the opposite result. Soon nobody knows who can change it, and every edit risks breaking three unrelated modules.

Temporary exceptions need a real expiry date, not a vague note in a ticket. Put the reason, owner, and removal date next to the rule so the whole team sees it during review.

The last check is the simplest. If a test fails, the merge stops. Human memory is unreliable, especially on a lean team shipping often. Automated gates are boring, and that is why they work.

What to do next with your team

Pick one backend service that causes small arguments every sprint. Map its modules on one page and give each module a clear job. If two people describe the same module in different words, fix that first.

A simple map is enough. You might end up with auth, billing, orders, notifications, and shared infrastructure. The point is not to make the diagram pretty. The point is to make imports feel obvious instead of debatable.

Then add only a few rules. Teams usually fail when they write twenty rules before they fix the first broken one. Start with two or three import rules that block the most common shortcuts: feature modules should not import each other directly, shared utilities should stay free of business logic, and API handlers should not reach into database code across module lines.

That small set is enough to begin without turning the test suite into a second policy document.

Fix current violations before you add more rules. If the test report already shows thirty bad imports, nobody will trust it. Clean up the obvious ones, merge the rules, and let the team see that the setup matches the real code.

Keep the setup boring. If people need a meeting to understand one rule, the rule is too complex. Good boundary tests should read like plain team agreements, not legal text.

A simple rhythm works well: map one service, choose two or three rules, remove current violations, run the checks in CI, and review the rules after the next release.

If several teams touch the same backend, outside review can save time. A fresh set of eyes often spots blurred ownership faster than the people who work inside the code every day.

The result is simple. Developers stop guessing, reviews get shorter, and one rushed release does not undo six months of cleanup. If your first rules block even one bad cross-module import before the next deploy, that is a solid start.

Architecture tests for module boundaries in fast backends | Oleg Sotnikov