Context! Context! Context! Part 1 of Beyond the Code: Designing Services That Stand the Test of Time
As software engineers, it’s easy to get lost in the excitement of crafting clever business logic: the algorithms, the workflows, the elegant domain models. However, the success or failure of a service rarely hinges on its core logic alone. What really separates a fragile prototype from a resilient, scalable and maintainable system is everything else that happens around that logic: the invisible scaffolding that shapes how a service behaves, communicates, and recovers when things go wrong.
In Part 1 of Beyond The Code: Designing Services That Stand the Test of Time, I’ll explore project layout and how the physical structure of a codebase affects engineers’ ability to understand, navigate, and maintain the system. Two ideas sit at the heart of this:
- Cognitive load - the mental effort required to figure out how pieces of a system fit together
- Cohesion - the principle that things which work closely together should live close together in the project.
These ideas first began to take shape during my time working with Java and later C#, but they have matured significantly through my recent experience with Node.js and TypeScript. While I believe the concepts can be applied across a range of languages and ecosystems, they should be viewed primarily through the lens of Node.js, TypeScript and ExpressJs. That said, they are unlikely to fit more opinionated languages such as Go or Rust. While I strongly believe this approach represents good practice, particularly for ExpressJS projects which are at least loosely based on the Model View Controller pattern, this article is ultimately an opinion piece. It should be treated as a set of guidelines rather than rules.
I’ve called this part, Context! Context! Context! as you should always be considering the context of where the code you are writing is used when deciding where to put it in the project, as well as what to call it.
From MVC to N-Tier
- Model - represents the business logic by defining the domain entities, their interactions, and the rules that govern their behavior and data.
- View - the graphical user interface for human users or the external interface through which other services and applications interact.
- Controller - serves as the mediator between the Model and the View, managing the flow of data and coordinating updates between them.
Routes: The views in services
N-Tier Architecture
- repositories
- routers
- services
- routers
- controllers
- services
- repositories
- services
Controllers aren’t the only entry point
- repositories
- routers
- controllers
- services
leanly expresses the architecture:
- routers/controllers handle synchronous HTTP requests
- listeners handle asynchronous or event-driven workloads
- both call into the same services and repositories
- both sit on the boundary of the system
- neither depends on the other
That’s exactly what a good project structure should communicate.
Collaboration implies Cohesion
“How far do I have to travel through this codebase to understand how two things work together?”
So a good rule of thumb is this:
Ideally, they should even be declared in the same file as that’s the tightest possible context. When you see the model, you see its repository right next to it. No hunting, no mental juggling.
However, languages sometimes have opinions about this. Java, for example, insists on one public class per file. If that’s the world you're in, you can still preserve the spirit of the rule. Put the model in a top level models directory and put its repository in a subdirectory inside it, or invert it if you think the repository is the primary entry point. The point isn’t the exact nesting; it’s reducing the distance between pieces of code that must be understood together.
There are also cases where collaborators can’t realistically share a file. For example, a User model may contain an Address model, and each may have its own repository. In this situation, the models themselves are closely related, so grouping them together, such as in a shared directory, keeps the cognitive distance small while still respecting their individual boundaries. The same applies to their repositories: the physical layout should mirror the conceptual relationships.
- models
- address.ts
- user.ts
- address-repo.ts
- user-repo.ts
That’s how you keep a project navigable, predictable and human readable.
When collaboration is hidden by design
In this situation you have:
- API response models
- conversion logic
- outbound client-facing models
- the client method performing the translation
You might have something like:
- payment-api-client.ts
- responses
- create-charge-response.ts
- converters
- charge-converter.ts
- models
- charge.ts
Code Beyond the Domain
Shared code
- mappers
- to-address-mapper.ts
- to-user-mapper.ts
- address-controller.ts
- user-controller.ts
- loyalty-discount.ts
- orders
- create-order-service.ts
- cancel-order-service.ts
- subscriptions
- renewSubscriptionService.ts
- upgradeSubscriptionService.ts
The principle is the same as before:
If two things collaborate, keep them close.
Shared does not mean global. Shared means, shared by these parts of the code, so place it where that relationship is visible.
Library code
This kind of library code has no real context within the architecture. It’s not tied to a particular model, controller, or repository. It doesn’t express business logic. It doesn’t reveal anything about how the system behaves. It’s simply reusable code with no specific context
Library code belongs right at the top level of the project: clearly visible, clearly isolated, clearly generic. When something can be used everywhere, it shouldn’t live inside any particular architectural tier, otherwise you give it a false sense of belonging and mislead the reader.
Library code should be grouped by context and pushed to the top level of the project, but you don’t want to end up with a wide and confusing top level. For example:
- now.ts
- repositories
- routers
- controllers
- services
- string
- to-lower.ts
- to-upper.ts
- uuid.ts
Group the library code at the edge of the project in an appropriately named directory:
- date
- now.ts
- string
- to-lower.ts
- to-upper.ts
- uuid.ts
- repositories
- routers
- controllers
- services
I’m not a fan of generic, less meaningful names like ‘lib’, but here it’s a good compromise. We’ll talk more about naming things later.
A simple rule for shared and library code is:
- Shared code lives near the code that shares it.
- Library code lives at the top level, because it belongs to no other code.
Where to put the Tests
Different languages and ecosystems support different conventions for test placement. In NodeJs, for example, you’ll often see one of three approaches:
- A separate test folder alongside src
- A __test__ directory inside src
- Test files placed directly next to the code they test
I favour test alongside src because it’s cleaner and less noisy. The tests don’t get in the way, but are no less important. The bigger question is whether tests should live beside the code they exercise, or in their own space.
Placing test files next to the code they test has some drawbacks:
- They clutter the project layout, making it harder to navigate the actual implementation.
- They need to be filtered out when building for release, adding friction to the build pipeline.
- Most importantly, they subtly encourage a mindset of testing the code rather than testing the feature.
Unit testing is valuable, but it can be restrictive if it becomes the only lens through which you view quality. A project that only tests individual functions in isolation risks missing the bigger picture: how those functions collaborate to deliver a feature.
I first learned the discipline of testing software end‑to‑end, from the outside in, when I read Growing Object‑Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce. Their approach emphasises starting with tests at the system’s public interface, treating the software as a black box, and only introducing unit tests when triangulating behaviour through that interface becomes difficult or cumbersome.
This philosophy aligns perfectly with the idea of context and cohesion: tests should live where they make the relationships between features and their verification obvious, not scattered in ways that obscure intent. By beginning at the boundary of the system and only drilling inward when necessary, you keep the cognitive load low, preserve clear architectural edges, and ensure that your test suite reflects the same navigable, predictable structure as the code itself.
A healthier approach is to think of tests in terms of features and behaviours rather than files and functions. Tests should answer questions like:
- Does the order creation flow work end-to-end?
- Does the subscription renewal logic handle edge cases correctly?
- Does the API return the right response when given invalid input?
These are questions about the system’s behaviour, not about whether a particular method returns the right value. Organising tests around features makes it easier to see what the system should do and whether it does it.
To reflect this philosophy, generally tests should live in their own top level directory, structured by feature rather than by file. For example:
- src
- controllers
- lib
- services
- repositories
- test
- orders
- create-order.test.ts
- cancel-order.test.ts
- subscriptions
- renew-subscription.test.ts
- upgrade-subscription.test.ts
Tests remain close enough to the code they exercise to preserve context, but not so close that they clutter or mislead. You can open the project and immediately see: here’s the implementation of orders, and here’s how we test orders. The relationship is visible without being noisy.
Unit tests still have their place, especially for complex algorithms or critical edge cases. But they should be balanced with integration and feature tests that validate behaviour across boundaries.
Just as thoughtful test placement reduces noise and clarifies intent, clear naming does the same for code, it shapes how we understand and navigate a system. Let’s have a look at how we might name things.
Naming
Kevlin Henney tells us that “One of the hardest things in software development is naming. Naming of products, of paradigms, and of parts of your code….” he goes on to explain that the compiler understands context from how and where code is used and we should too to remove unnecessary verbosity from names:
If your programming language denotes abstract classes with a keyword such as abstract, don’t repeat yourself by putting Abstract in the name or by telling the reader that its necessary use is as base class by naming it Base. If a class is a concrete class then, by definition, it is an implementation class, so don’t repeat yourself by putting Impl in the name. If your compiler and IDE can tell an integer from a string, don’t repeat yourself by encoding that detail in a variable name, as popularised by the once-popular Hungarian encryption scheme. If your testing framework has you mark your tests with a Test annotation, attribute or macro - and your test appears inside a class, file or folder named Test - don’t repeat yourself by including Test in the name of your test. Use the bandwidth of a name to tell the reader things they need to know rather than repeating what is already known. Use your bandwidth for signal rather than noise - and use less bandwidth.
-- Exceptional Naming, Kevlin Henney
When naming files, apply the same principle of signal over noise. The name should communicate the file’s purpose in its immediate context without redundant prefixes or suffixes. For example, a controller that handles user related operations should simply be named user-controller.ts. If the file is inside a controllers directory, you don’t need to repeat ‘controller’ in the name unless it adds clarity. user.ts inside controllers is often sufficient because the directory already conveys the role.
Context matters, the directory structure provides part of the meaning, so avoid duplicating that meaning in the filename.
However, this guideline should also be applied thoughtfully rather than rigidly. In most cases, avoiding redundant prefixes or suffixes keeps filenames clean and meaningful within their directory context. However, when searching across a large codebase, filenames often appear without their full path, which can lead to ambiguity. In those edge cases, adding a clarifying term, such as user-controller.ts instead of just user.ts, may improve discoverability without significantly muddying the naming convention.
If a file starts to accumulate too much responsibility, for example, multiple controllers, complex orchestration, or just too much code, it’s time to split it into smaller, focused files. The guiding principle is to preserve cohesion, keep tightly coupled code together in a well named directory so the reader understands their relationship without hunting across the project, and avoid fragmentation, don’t scatter related logic into distant directories. Instead, create a subdirectory that reflects the shared context.
For example, if user-controller.ts grows to handle multiple distinct flows, such as registration, profile updates, authentication, you might restructure like this:
- controllers
- user
- auth-controller.ts
- profile-controller.ts
- register-controller.ts
This approach keeps the cognitive load low. Everything related to user controllers is in one place, and each file has a clear, single responsibility.
Listeners, models, repositories, services, shared code, library code, tests and everything else that grows beyond a single file should follow the same pattern. Group them by context in subdirectories, and name the files according to their specific role. This keeps the project navigable and predictable, reducing cognitive load while preserving clear boundaries.
Why “helpers,” “utils,” and “lib” Are Bad Names
Directories named helpers, utils, lib, Extensions, etc. are a common source of frustration, and for good reason. These names are vague, interchangeable, and often scattered inconsistently across a project. Worse, they mislead! So called “helpers” aren’t helping; they’re doing real things. These generic labels add cognitive noise instead of clarity.
As we’ve discussed already, meaningful directory names are always better. A name should tell you what the code does or what domain it belongs to, not that it’s vaguely useful. For example, if the code formats dates, put it in a date directory. If it handles string manipulation, use string. Each name should reflect the actual responsibility of the code.
When you truly have code that is global and context free, small, reusable bits of code that don’t belong to any specific domain, then and only then use single, consistent directory naming like lib. This is the least bad option because it signals generic library code without pretending to describe a purpose it doesn’t have.
Don’t forget to watch as things grow and change. Don’t miss when the domain emerges and needs refactoring.
Finally
Designing a service that stands the test of time isn’t just about writing clean code, it’s about creating a structure that makes sense to the people who will live with it. Architecture and naming are silent guides for every future decision, shaping how easily others can navigate, extend, and trust the system.
While N‑tier architectures offer clear separation of concerns, they’re not without trade‑offs. Adding too many layers without a clear purpose can lead to architectural bloat, slowing development and increasing cognitive overhead. Each tier can introduce complexity in communication and testing, so it’s important to balance modularity with simplicity. Layers should exist because they solve a real problem, not because the pattern says they should.
The principles outlined here aren’t rigid rules; they’re lenses for thinking. Every project has quirks, every team has constraints, but the goal remains the same: clarity over chaos. By grouping related code, naming with intent, and resisting the temptation of vague catch-all directories, you create a system that communicates its design without explanation. That’s the hallmark of a codebase built for longevity.
Ultimately, good structure is an investment. It pays dividends in reduced friction, faster onboarding, and fewer bugs, incidents and surprises when change inevitably comes. If you take one thing away, let it be this: code doesn’t live in isolation. Its context matters, today, tomorrow, and for every engineer who touches it next.
Acknowledgments
Thank you to the following for your reviews and comments and helping to expand my ideas:
Dom Davis
Laurent Douchy
Alex Leslie
Jon Moore
Chirs Oldwood
Kevin Richards
Nayana Solanki
References
Growing Object-Oriented Software, Guided by Tests
Steve Freeman and Nat Pryce
ISBN: 9780321503626
Exceptional Naming, Kevlin Henney
* Triangulation means writing enough tests against the public interface to constrain and validate all essential parts of the internal implementation without directly testing private details.
.png)



Comments
Post a Comment