Skip to main content

Repository — A Pattern Outside GoF That Comes Up Often in Practice

This chapter states up front that the pattern sits outside GoF and then narrows to expressing it in types.

Where this chapter stands

Repository is not among GoF's 23 patterns1. It is a pattern catalogued in Martin Fowler's Patterns of Enterprise Application Architecture2. It appears here as a supplementary chapter outside GoF, on this guide's judgment that it comes up often in practice.

This site already has two articles covering Repository. To keep from overlapping with them, this chapter narrows to one question: how do TypeScript's types express this boundary? For the explanation of the pattern itself and its place in the DDD context, the existing articles below are the authority.

The problem this pattern set out to solve

Fowler defines it this way.

Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.2

The key phrase is collection-like interface. The aim is for the calling side to feel like it is touching an in-memory collection rather than a database.

A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction.2

From this comes the standard for judging a Repository: has anything about persistence leaked into the calling code? Whether SQL or table names show up directly is the center of it.

The second half of the quotation points at a form where queries are assembled and submitted as declarative specification objects. This chapter does not adopt that form. The reason, and what would overturn that decision, are in How this series judges it.

Writing it straightforwardly with classes

A generic CRUD interface is the first thing that comes to mind.

type Bookmark = {
id: string;
url: string;
title: string;
tags: string[];
createdAt: Date;
};

interface CrudRepository<T> {
findAll(): Promise<T[]>;
findById(id: string): Promise<T | undefined>;
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}

type BookmarkRepository = CrudRepository<Bookmark>;

It looks reusable, but from the caller's side there are holes.

  • findById(id: string) accepts any string at all. Pass a user ID by mistake and it still compiles.
  • save(entity: Bookmark) demands the complete shape, id and createdAt included. A bookmark that has not been saved yet has neither of them. The caller either fills in placeholder values or papers over it with as.
  • There is nowhere to express a query specific to that domain, such as "find by tag." You either findAll() and filter afterward, or bolt on a separate specification object.

The first two are leaks that move away from "feels like touching a collection." The third is less a leak than a matter of a generic type having a narrow range of what it can express.

Replacing it with language features

TypeScript's types can close all three.

First, IDs. Leaving them as string makes mix-ups undetectable, so you attach a mark that only that type can be assigned to.

declare const brand: unique symbol;

// A string, but a type usable only as a BookmarkId
type BookmarkId = string & {readonly [brand]: 'BookmarkId'};
type UserId = string & {readonly [brand]: 'UserId'};

function toBookmarkId(raw: string): BookmarkId {
return raw as BookmarkId; // The conversion happens only inside this function
}

declare function findById(id: BookmarkId): Promise<unknown>;

const ok = findById(toBookmarkId('bm_1'));

BookmarkId and UserId are both strings underneath, but neither can be assigned to the other. A mix-up stops at compile time.

Example that produces a type error
// error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'BookmarkId'.
declare const userId: UserId;
findById(userId);

Next, the difference between the shape before saving and after. Omit expresses it.

Constructs a type by picking all properties from Type and then removing Keys (string literal or union of string literals).3

The next example redeclares brand so the fence type-checks on its own. In real code, put the brand declaration in one place and share it. Declared separately, unique symbol becomes a different type, and two BookmarkId types with the same name stop being assignable to each other.

declare const brand: unique symbol;
type BookmarkId = string & {readonly [brand]: 'BookmarkId'};

type Bookmark = {
id: BookmarkId;
url: string;
title: string;
tags: string[];
createdAt: Date;
};

// id and createdAt are decided by the store. The caller does not need to write them
type NewBookmark = Omit<Bookmark, 'id' | 'createdAt'>;

interface BookmarkRepository {
findById(id: BookmarkId): Promise<Bookmark | undefined>;
findByTag(tag: string): Promise<Bookmark[]>;
add(draft: NewBookmark): Promise<Bookmark>;
remove(id: BookmarkId): Promise<void>;
}

async function bookmarkArticle(repository: BookmarkRepository): Promise<Bookmark> {
// Neither id nor createdAt is written. Adding them here becomes a type error
return repository.add({
url: 'https://example.com/article',
title: 'Design Patterns',
tags: ['typescript'],
});
}

All three holes are closed.

  • The ID cannot be mixed up
  • add takes the pre-save shape and returns the post-save shape
  • findByTag expresses the query in the domain's own words

That said, add rejects excess properties only when the object literal is written directly in the argument. Put it in a variable first and an object carrying id or createdAt gets through. What Omit expresses is that you do not need to write them, not that you cannot.

The generic CrudRepository<T> is gone. Making it a named interface per aggregate puts the ID type, the pre- and post-save shapes, and the vocabulary of the queries each into the types.

Expressing "not found" as Bookmark | undefined is deliberate as well. Putting absence into the return type rather than into an exception forces the caller to narrow.

How this series judges it

Verdict: conditional. Whether to place a Repository and how to type it if you do are separate questions.

On whether to place one:

  • Place one if you want queries expressed in the domain's words, or if you want to swap the persistence implementation — that includes swapping in a memory implementation for tests.
  • Do not place one if the ORM already offers a collection-like entry point and there is no motivation to abstract further — inserting a layer that only delegates just adds one more place to read.

Once you decide to place one, the verdict on typing lands on one side. Use a named interface per aggregate rather than a generic Repository<T>. Go generic and the three holes above remain as they were, which thins out the point of having a Repository at all.

Not adopting the declarative specification objects the source raises comes from the same reasoning. Make the search conditions a generic object and the type stops showing which combinations are valid. Give them names, as findByTag(tag: string) does, and the types enumerate the queries you can make. This is a position that differs from the source.

One more thing: counting "being forced to use the same type before and after saving" as a leak is also this guide's position. The catalog page quoted above says nothing about that distinction.

What would overturn this judgment

A marked ID creates a conversion boundary. Turning a string read from the database into a BookmarkId means writing as somewhere. It takes the discipline of placing one conversion function, like toBookmarkId above, and writing it nowhere else. On a team too large to hold that discipline, the mark will not prevent mix-ups.

Another: writing an interface per aggregate means as many interfaces as aggregates. With 20 aggregates that genuinely all carry the same CRUD, the generic type comes out smaller in total. That situation is also a sign, though, that what you wanted was a data access layer, not a Repository.

The decision to use named methods also falls apart when filter conditions grow combinatorially. A search that freely combines sort order, date range, multiple tags, and paging needs as many method names as there are combinations. Once you are there, the specification objects the source raises are the more natural fit.

How this relates to existing articles

Two articles overlap with this chapter. They cover different faces of it, so pick by what you need.

  • The Repository Pattern — covers it in PHP and Laravel from the DDD context. It includes that PoEAA is the source, why the interface belongs in the domain layer, and the N+1 problem and eager loading. That article is the authority on the pattern itself.
  • Inheritance and interfaces — has an example writing a generic Repository<T> and a memory implementation in TypeScript, positioned as a practical example of implements. This chapter starts from that generic form and asks what happens when you push more into the types, so it reads as a continuation.
  • Naming conventions 2: functions and classes lists ~Repository as a suffix for the data access layer, with a class carrying findById / save as its example. In this chapter's terms, that shape is the generic CRUD. Same name, different referent.

The Omit used in this chapter is covered in Utility types. The marked type (a branded type) is touched on from the motivation side in Adapter under "What would overturn this judgment," but this site has no article showing how to write one, so use this chapter's example.

Footnotes

  1. The list of GoF's 23 patterns was confirmed via a secondary source. Repository is not among them. Source: Design Patterns (Wikipedia).

  2. Source: Repository (the catalog page for Martin Fowler's Patterns of Enterprise Application Architecture). 2 3

  3. Source: Utility Types (TypeScript Handbook), Omit<Type, Keys>.