The wrong question
Some years ago, one of my teams asked which dependency injection (DI) pattern they should adopt for a TypeScript-on-Node.js service. I encouraged them to make their own call and later learned the decision had effectively been deferred. The codebase ended up with a mix of factory methods, manual constructor injection, and root-module wiring, each style bringing its own testing burden. Some modules were unit-testable; others required heavyweight HTTP-aware scaffolding for trivial logic. Changes in one namespace silently broke contracts in unrelated areas.
With hindsight, the team had asked the wrong question. Instead of “which DI pattern?”, they should have asked: “what qualities do we want in this codebase, and which composition techniques lead us there?” The qualities that matter most to me:
- Discrete modules with minimal incidental coupling, even at the cost of duplicated types.
- Business logic kept apart from transport concerns like HTTP handlers or GraphQL resolvers.
- Business logic tests that need no transport scaffolding.
- Tests that survive new fields being added to types.
- A small surface of types exposed outside their module or directory.
Over the past few years I have settled on a composition strategy grounded in test-driven development (TDD) and a “function-first” mindset. Rather than describing it abstractly, I will walk through a worked example: a restaurant recommendation service built with TypeScript, Node.js, and PostgreSQL.
The problem
Consider this user story: a registered user of RateMyMeal wants a ranked list of recommended restaurants in their city, based on other patrons’ ratings.
The acceptance criteria:
- The list ranks restaurants from most to least recommended.
- Ratings are
excellent (2),above average (1),average (0),below average (-1), orterrible (-2). - Overall rating is the sum of individual ratings.
- Ratings from “trusted” users get a 4x multiplier.
- City is required to scope the query.
I start with a coarse integration test as a walking skeleton. This test exercises as much real infrastructure as possible, stubbing only third-party providers or network clients that cannot run locally. It becomes my acceptance test for the feature, though I deliberately keep it to one happy path; edge cases are covered by cheaper unit-level tests.
From when/then to given
I prefer to write tests in the given/when/then shape, starting from the expected outcome and working backward to preconditions. The when/then for this feature is straightforward:
“When I call the recommendation endpoint, then I get an OK response with top-rated restaurants based on the ratings algorithm.”
In code, that looks like:
describe("the restaurants endpoint", () => {
it("ranks by the recommendation heuristic", async () => {
const response = await axios.get<ResponsePayload>( ➀
"http://localhost:3000/vancouverbc/restaurants/recommended",
{ timeout: 1000 },
);
expect(response.status).toEqual(200);
const data = response.data;
const returnRestaurants = data.restaurants.map(r => r.id);
expect(returnRestaurants).toEqual(["cafegloucesterid", "burgerkingid"]); ➁
});
});
type ResponsePayload = {
restaurants: { id: string; name: string }[];
};
Two design points are worth noting:
- I use the
AxiosHTTP client with a type argument (ResponsePayload) that describes the expected response structure. The compiler enforces that my use ofresponse.datamatches that type, but this is a compile-time guarantee only; the runtime body could still differ. The assertions handle that. - I only assert on restaurant ids, not full object contents. Checking the whole object would make the test brittle: adding a field would break it. I want a test that verifies the ordering condition while tolerating the natural growth of the code.
Without preconditions, that test is worthless. Adding them:
describe("the restaurants endpoint", () => {
let app: Server | undefined;
let database: Database | undefined;
const users = [
{ id: "u1", name: "User1", trusted: true },
{ id: "u2", name: "User2", trusted: false },
{ id: "u3", name: "User3", trusted: false },
];
const restaurants = [
{ id: "cafegloucesterid", name: "Cafe Gloucester" },
{ id: "burgerkingid", name: "Burger King" },
];
const ratingsByUser = [
["rating1", users[0], restaurants[0], "EXCELLENT"],
["rating2", users[1], restaurants[0], "TERRIBLE"],
["rating3", users[2], restaurants[0], "AVERAGE"],
["rating4", users[2], restaurants[1], "ABOVE_AVERAGE"],
];
beforeEach(async () => {
database = await DB.start();
const client = database.getClient();
await client.connect();
try {
// GIVEN
// These functions don't exist yet, but I'll add them shortly
for (const user of users) {
await createUser(user, client);
}
for (const restaurant of restaurants) {
await createRestaurant(restaurant, client);
}
for (const rating of ratingsByUser) {
await createRatingByUserForRestaurant(rating, client);
}
} finally {
await client.end();
}
app = await server.start(() =>
Promise.resolve({
serverPort: 3000,
ratingsDB: {
...DB.connectionConfiguration,
port: database?.getPort(),
},
}),
);
});
afterEach(async () => {
await server.stop();
await database?.stop();
});
it("ranks by the recommendation heuristic", async () => {
// .. snip
The given conditions live in beforeEach, which keeps setup independent of individual tests and makes it easy to add more tests that share the scaffold. Note the pervasive await. On reactive platforms like Node.js, everything that could touch I/O—database calls, file reads—should use an asynchronous contract from day one. Wrapping a synchronous implementation in a Promise is trivial; retrofitting async onto a synchronous interface is painful.
I have intentionally not defined explicit types for users and restaurants. I do not yet know their shapes, and TypeScript’s structural typing lets me defer those definitions while APIs begin to solidify. As we will see, this is a crucial tool for keeping modules uncoupled: each module can define exactly the type it needs, and as long as the structure matches, nothing else matters.
With the dependencies in place, the next stage is building the test collaborators—starting services like Postgres via Docker (I like testcontainers for that), writing SQL INSERTs for the create... helpers, and starting the service itself, which is a stub at this point. The service startup is directly relevant to composition, so I cover it next. Before all that, though, I run the test and confirm it fails as expected: the service hasn’t been started, so the HTTP call yields a connection refused error. Then I disable the integration test and commit, since it will not pass for a while.
Building the controller contract
With a clear view of what acceptance looks like, I move to the HTTP layer. I start with a controller unit test that expects an empty 200 response with the proper headers. The test itself is unremarkable until you notice I design the controller as a factory function that takes a dependencies object. That distinction is deliberate: it's the first move toward partial application, where functions return functions bound to injected context.
describe("the ratings controller", () => {
it("provides a JSON response with ratings", async () => {
const ratingsHandler: Handler = controller.createTopRatedHandler();
const request = stubRequest();
const response = stubResponse();
await ratingsHandler(request, response, () => {});
expect(response.statusCode).toEqual(200);
expect(response.getHeader("content-type")).toEqual("application/json");
expect(response.getSentBody()).toEqual({});
});
});
The first stub of the controller fails, as expected, because nothing calls status. A minimal implementation fixes that:
export const createTopRatedHandler = () => {
return async (request: Request, response: Response) => {};
};
export const createTopRatedHandler = () => {
return async (request: Request, response: Response) => {
response.status(200).contentType("application/json").send({});
};
};
Now I flesh out the test to cover the payload. I don't yet know how data access will work, but I know the controller should only translate between HTTP and the domain. That means delegating the real work to a function that returns top-rated restaurants. I define that as a dependency in my stub, keeping the controller completely agnostic to where the data comes from or how it's computed:
type Restaurant = { id: string };
type RestaurantResponseBody = { restaurants: Restaurant[] };
const vancouverRestaurants = [
{
id: "cafegloucesterid",
name: "Cafe Gloucester",
},
{
id: "baravignonid",
name: "Bar Avignon",
},
];
const topRestaurants = [
{
city: "vancouverbc",
restaurants: vancouverRestaurants,
},
];
const dependenciesStub = {
getTopRestaurants: (city: string) => {
const restaurants = topRestaurants
.filter(restaurants => {
return restaurants.city == city;
})
.flatMap(r => r.restaurants);
return Promise.resolve(restaurants);
},
};
const ratingsHandler: Handler =
controller.createTopRatedHandler(dependenciesStub);
const request = stubRequest().withParams({ city: "vancouverbc" });
const response = stubResponse();
await ratingsHandler(request, response, () => {});
expect(response.statusCode).toEqual(200);
expect(response.getHeader("content-type")).toEqual("application/json");
const sent = response.getSentBody() as RestaurantResponseBody;
expect(sent.restaurants).toEqual([
vancouverRestaurants[0],
vancouverRestaurants[1],
]);
The controller only needs to know the contract: an unbound async function returning a set of restaurants. Whether that's a static function, a method, or a test stub is irrelevant. This is the essence of decoupling — expose minimum requirements, nothing more.
interface Restaurant {
id: string;
name: string;
}
interface Dependencies {
getTopRestaurants(city: string): Promise<Restaurant[]>;
}
export const createTopRatedHandler = (dependencies: Dependencies) => {
const { getTopRestaurants } = dependencies;
return async (request: Request, response: Response) => {
const city = request.params["city"]
response.contentType("application/json");
const restaurants = await getTopRestaurants(city);
response.status(200).send({ restaurants });
};
};
The controller is far from complete; edge cases and error paths need coverage. But the direction is set. The architecture around this function will radiate outward from this contract.
Into the domain layer
Now I need something to fulfil the getTopRestaurants contract. I write a throwaway unit test first, only then thinking about implementation details. The test introduces several new domain concepts, so let me unpack them:
- I need a finder that returns sets of ratings per restaurant. I stub that out.
- The acceptance criteria drive the overall rating algorithm, but for now I defer that and simply say the ratings will, somehow, produce a numeric overall score.
- This module needs two new injected functions: finding ratings for a restaurant, and turning ratings into an overall value.
- Types like
RatingsByRestaurantandRestaurantRatingexist first in test code. They may graduate to production later; Typescript makes types cheap enough to defer that decision. - The rating values — excellent (2), above average (1), average (0), below average (-1), terrible (-2) — also live in the test initially, as domain concepts whose final home isn't settled.
describe("The top rated restaurant list", () => {
it("is calculated from our proprietary ratings algorithm", async () => {
const ratings: RatingsByRestaurant[] = [
{
restaurantId: "restaurant1",
ratings: [
{
rating: "EXCELLENT",
},
],
},
{
restaurantId: "restaurant2",
ratings: [
{
rating: "AVERAGE",
},
],
},
];
const ratingsByCity = [
{
city: "vancouverbc",
ratings,
},
];
const findRatingsByRestaurantStub: (city: string) => Promise< ➀
RatingsByRestaurant[]
> = (city: string) => {
return Promise.resolve(
ratingsByCity.filter(r => r.city == city).flatMap(r => r.ratings),
);
};
const calculateRatingForRestaurantStub: ( ➁
ratings: RatingsByRestaurant,
) => number = ratings => {
// I don't know how this is going to work, so I'll use a dumb but predictable stub
if (ratings.restaurantId === "restaurant1") {
return 10;
} else if (ratings.restaurantId == "restaurant2") {
return 5;
} else {
throw new Error("Unknown restaurant");
}
};
const dependencies = { ➂
findRatingsByRestaurant: findRatingsByRestaurantStub,
calculateRatingForRestaurant: calculateRatingForRestaurantStub,
};
const getTopRated: (city: string) => Promise<Restaurant[]> =
topRated.create(dependencies);
const topRestaurants = await getTopRated("vancouverbc");
expect(topRestaurants.length).toEqual(2);
expect(topRestaurants[0].id).toEqual("restaurant1");
expect(topRestaurants[1].id).toEqual("restaurant2");
});
});
interface Restaurant {
id: string;
}
interface RatingsByRestaurant { ➃
restaurantId: string;
ratings: RestaurantRating[];
}
interface RestaurantRating {
rating: Rating;
}
export const rating = { ➄
EXCELLENT: 2,
ABOVE_AVERAGE: 1,
AVERAGE: 0,
BELOW_AVERAGE: -1,
TERRIBLE: -2,
} as const;
export type Rating = keyof typeof rating;
The initial implementation is deliberately minimal, just enough to make the test compile and fail in the expected way. As I build it out, I discover that some concepts must move into production code:
interface Dependencies {}
export const create = (dependencies: Dependencies) => { ➀
return async (city: string): Promise<Restaurant[]> => [];
};
interface Restaurant { ➁
id: string;
}
export const rating = { ➂
EXCELLENT: 2,
ABOVE_AVERAGE: 1,
AVERAGE: 0,
BELOW_AVERAGE: -1,
TERRIBLE: -2,
} as const;
export type Rating = keyof typeof rating;
Some types stay in the test module because they aren't direct dependencies yet. But the Rating type gets promoted early — the values are cited explicitly in the acceptance criteria, so the coupling is real and not incidental.
- I fill out the
Dependenciestype fully. OverallRatingcaptures the domain concept of a restaurant id paired with its score.- I extract the types that are now direct dependencies of
topRated. - The core function logic becomes concrete.
interface Dependencies { ➀
findRatingsByRestaurant: (city: string) => Promise<RatingsByRestaurant[]>;
calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number;
}
interface OverallRating { ➁
restaurantId: string;
rating: number;
}
interface RestaurantRating { ➂
rating: Rating;
}
interface RatingsByRestaurant {
restaurantId: string;
ratings: RestaurantRating[];
}
export const create = (dependencies: Dependencies) => { ➃
const calculateRatings = (
ratingsByRestaurant: RatingsByRestaurant[],
calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number,
): OverallRating[] =>
ratingsByRestaurant.map(ratings => {
return {
restaurantId: ratings.restaurantId,
rating: calculateRatingForRestaurant(ratings),
};
});
const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
const { findRatingsByRestaurant, calculateRatingForRestaurant } =
dependencies;
const ratingsByRestaurant = await findRatingsByRestaurant(city);
const overallRatings = calculateRatings(
ratingsByRestaurant,
calculateRatingForRestaurant,
);
const toRestaurant = (r: OverallRating) => ({
id: r.restaurantId,
});
return sortByOverallRating(overallRatings).map(r => {
return toRestaurant(r);
});
};
const sortByOverallRating = (overallRatings: OverallRating[]) =>
overallRatings.sort((a, b) => b.rating - a.rating);
return getTopRestaurants;
};
//SNIP ..
Both modules — controller and domain — pass their isolated tests without any knowledge of each other. The only way to prove they can work together is to wire them up.
First integration
I want to integrate these pieces early, before too many assumptions pile up. If my unit tests are pebbles and the final acceptance test is a boulder, this is a fist-sized rock: one that exercises the controller into the first domain layer, with stubs standing in for anything deeper.
describe("the controller top rated handler", () => {
it("delegates to the domain top rated logic", async () => {
const returnedRestaurants = [
{ id: "r1", name: "restaurant1" },
{ id: "r2", name: "restaurant2" },
];
const topRated = () => Promise.resolve(returnedRestaurants);
const app = express();
ratingsSubdomain.init(
app,
productionFactories.replaceFactoriesForTest({
topRatedCreate: () => topRated,
}),
);
const response = await request(app).get(
"/vancouverbc/restaurants/recommended",
);
expect(response.status).toEqual(200);
expect(response.get("content-type")).toBeDefined();
expect(response.get("content-type").toLowerCase()).toContain("json");
const payload = response.body as RatedRestaurants;
expect(payload.restaurants).toBeDefined();
expect(payload.restaurants.length).toEqual(2);
expect(payload.restaurants[0].id).toEqual("r1");
expect(payload.restaurants[1].id).toEqual("r2");
});
});
interface RatedRestaurants {
restaurants: { id: string; name: string }[];
}
I could lean on test framework stubbing to reach into unreachable modules, but I'd rather not expose internals just for testing. Instead, the factory functions accept an optional override mechanism in init() — a conventional seam for development that I can drop later if it stops earning its keep.
export const init = (
express: Express,
factories: Factories = productionFactories,
) => {
// TODO: Wire in a stub that matches the dependencies signature for now.
// Replace this once we build our additional dependencies.
const topRatedDependencies = {
findRatingsByRestaurant: () => {
throw "NYI";
},
calculateRatingForRestaurant: () => {
throw "NYI";
},
};
const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
const handler = factories.handlerCreate({
getTopRestaurants, // TODO: <-- This line does not compile right now. Why?
});
express.get("/:city/restaurants/recommended", handler);
};
interface Factories {
topRatedCreate: typeof topRated.create;
handlerCreate: typeof createTopRatedHandler;
replaceFactoriesForTest: (replacements: Partial<Factories>) => Factories;
}
export const productionFactories: Factories = {
handlerCreate: createTopRatedHandler,
topRatedCreate: topRated.create,
replaceFactoriesForTest: (replacements: Partial<Factories>): Factories => {
return { ...productionFactories, ...replacements };
},
};
Some contracts don't have real implementations yet. I stub them inline as functions that throw — the acceptance tests will catch any path that reaches them.
Exposing a real integration bug
Integration immediately surfaces a compile error. The Restaurant type in the controller expects a name field, while the one in topRated.ts doesn't have it. This is the cost of keeping types separate per layer — but it's also the point. If both shared a single common type, adding the field once would compile everywhere. But that shared type would also couple layers that needn't know each other's shape.
The trade-off is extra template code for reduced coupling. A quick patch to topRated.ts restores compilation:
interface Restaurant {
id: string;
name: string,
}
const toRestaurant = (r: OverallRating) => ({
id: r.restaurantId,
// TODO: I put in a dummy value to
// start and make sure our contract is being met
// then we'll add more to the testing
name: "",
});
The quick fix works — my tests pass and I can continue. The mapping of restaurant data needs a proper permanent solution, but that comes next, once the wiring is proven sound.
Filling in the data access contract
With the core flow of getTopRestaurants in place, the next step is implementing toRestaurant to load the full Restaurant object. Rather than designing a repository interface upfront, the test defines exactly what's needed: a finder function for loading a restaurant by ID, registered in the dependencies object, with validation that the returned name matches the loaded object.
const restaurantsById = new Map<string, any>([
["restaurant1", { restaurantId: "restaurant1", name: "Restaurant 1" }],
["restaurant2", { restaurantId: "restaurant2", name: "Restaurant 2" }],
]);
const getRestaurantByIdStub = (id: string) => { ➀
return restaurantsById.get(id);
};
//SNIP...
const dependencies = {
getRestaurantById: getRestaurantByIdStub, ➁
findRatingsByRestaurant: findRatingsByRestaurantStub,
calculateRatingForRestaurant: calculateRatingForRestaurantStub,
};
const getTopRated = topRated.create(dependencies);
const topRestaurants = await getTopRated("vancouverbc");
expect(topRestaurants.length).toEqual(2);
expect(topRestaurants[0].id).toEqual("restaurant1");
expect(topRestaurants[0].name).toEqual("Restaurant 1"); ➂
expect(topRestaurants[1].id).toEqual("restaurant2");
expect(topRestaurants[1].name).toEqual("Restaurant 2");
The getRestaurantById function returns a value wrapped in Promise, since the restaurant data will come from an external source. This makes the mapping code asynchronous, which complicates toRestaurant slightly:
const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
const {
findRatingsByRestaurant,
calculateRatingForRestaurant,
getRestaurantById,
} = dependencies;
const toRestaurant = async (r: OverallRating) => { ➀
const restaurant = await getRestaurantById(r.restaurantId);
return {
id: r.restaurantId,
name: restaurant.name,
};
};
const ratingsByRestaurant = await findRatingsByRestaurant(city);
const overallRatings = calculateRatings(
ratingsByRestaurant,
calculateRatingForRestaurant,
);
return Promise.all( ➁
sortByOverallRating(overallRatings).map(r => {
return toRestaurant(r);
}),
);
};
The key consideration is that the restaurant lookups should not run serially, or the IO-bound loads would delay the entire request. Promise.all collapses the collection of promises into a single promise containing a collection, allowing requests to go out in parallel. For a top-10 list, that concurrency level is fine. In a larger application, restructuring the service calls to load the name field via a database join would eliminate the extra calls entirely. If that isn't an option — say, when querying an external API — batching or using an async pool library like Tiny Async Pool would help manage concurrency.
The assembly module gets a dummy implementation so everything compiles, then remaining contracts can be fulfilled:
export const init = (
express: Express,
factories: Factories = productionFactories,
) => {
const topRatedDependencies = {
findRatingsByRestaurant: () => {
throw "NYI";
},
calculateRatingForRestaurant: () => {
throw "NYI";
},
getRestaurantById: () => {
throw "NYI";
},
};
const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
const handler = factories.handlerCreate({
getTopRestaurants,
});
express.get("/:city/restaurants/recommended", handler);
};
Implementing the domain layer dependencies
With the controller and top-level workflow in place, the remaining dependencies are the database access layer and the weighted rating algorithm. The implementation process follows the same pattern used throughout: write a test to drive out the basic design and a Dependencies type if needed, build the logical flow to make the test pass, then implement the module's dependencies and repeat.
Full working code is available in the repo. Two design decisions from the final implementation deserve additional comment.
First, the ratings algorithm is implemented as a pure function rather than a factory:
interface RestaurantRating {
rating: Rating;
ratedByUser: User;
}
interface User {
id: string;
isTrusted: boolean;
}
interface RatingsByRestaurant {
restaurantId: string;
ratings: RestaurantRating[];
}
export const calculateRatingForRestaurant = (
ratings: RatingsByRestaurant,
): number => {
const trustedMultiplier = (curr: RestaurantRating) =>
curr.ratedByUser.isTrusted ? 4 : 1;
return ratings.ratings.reduce((prev, curr) => {
return prev + rating[curr.rating] * trustedMultiplier(curr);
}, 0);
};
This choice signals that the calculation should remain simple and stateless. If a more complex implementation were anticipated — say, a data science model parameterized per user — the factory pattern would leave an easier pathway for evolution. The distinction is about leaving a trail for how the software might change: more rigid code in areas expected to stay stable, more flexibility where direction is less certain.
Second, ratingsAlgorithm.ts defines its own RestaurantRating type even though it's structurally identical to the one in topRated.ts. Two alternatives exist: exporting RestaurantRating from topRated.ts and importing it directly, or factoring shared types into a common types.ts module. The choice here is deliberate — the types might represent different projections of the same domain entity, and sharing them across module boundaries risks deeper coupling. Collapsing identical entities later is cheap and easy. Pulling apart types that have already been bound together across modules is far trickier.
The value of structural typing
This is why types are often not exported: making a type available to another module only invites incidental coupling that restricts evolution. TypeScript's structural typing makes it easy to keep modules decoupled while still guaranteeing contracts at compile time. As long as types are compatible in both caller and callee, the code compiles without shared type definitions.
More rigid languages like Java or C# force earlier decisions. Implementing the ratings algorithm in such a language would require one of several approaches:
- Extract
RestaurantRatinginto a shared location, letting other functions bind to it and increasing coupling. - Create two distinct
RestaurantRatingtypes with an adapter function for translation — correct but adding boilerplate just to satisfy the compiler. - Collapse the algorithm into the
topRatedmodule, burdening it with more responsibilities than desired.
Martin Fowler's 2004 article on dependency injection mentions using a role interface to reduce coupling in Java, despite that language's lack of structural types or first-class functions. That approach remains relevant for similar situations in Java. Ports of this project to Kotlin and Go show the pattern applies, but not without adjustments — documented in the respective repositories.
Summary
Binding dependency contracts to functions rather than classes, minimizing shared types across module boundaries, and letting tests drive the design produces a system of highly discrete, type-safe modules that can evolve independently. That approach suits projects with similar priorities. But foundational choices are rarely about selecting the best practice in isolation — tech stack idioms and team skills matter just as much. Each way of composing a system carries its own tradeoffs, which is what makes software architecture both difficult and engaging.



