My currently working on an application to tracking projects in our organisation. As part of this I defined this specific repository interface to get and store application and project information in a database.

The specific interface

export interface Container {
    // store and fetch from DB
    getRepository: () => Repository
}
export interface Repository {
    createProject: (project: NamedEntity) => Promise<unknown>
    getProject: (id: number) => Promise<SavedEntity|void>
    listProjects: () => Promise<SavedEntity[]>
    deleteProject: (projectId: number) => Promise<unknown>

    createApplication: (request: NamedProjectEntity) => Promise<unknown>;
    getApplication: (id: number) => Promise<SavedProjectEntity|void>
    listApplications: () => Promise<SavedProjectEntity[]>;
    listProjectApplications: (projectId: number) => Promise<SavedProjectEntity[]>;
    deleteApplication: (applicationId: number) => Promise<unknown>
}

I’ve been adding to this piece by piece using end-2-end development starting with the createProject and listProjects. Quickly becoming a crud + list and search shaped repository.

It’s easy to understand and simple to change

  • Adding new functions is easy
  • Writing the implementation is easy
  • The intent is pretty clear

Porblems

  • Sprawling implementations
  • Continual changes to tests
  • Naming consistency
  • Adding new entities

Solution - Generic Repository Interface

export interface Container {
    // store and fetch from DB
    getRepository: <N extends EntityNames>(name: N) => RepositoryOf<N>
}
// This type allows individual entities to have customised repositories, 
// either using the generic Resource or a custom one
export type entities = {
    name: 'project'
    repository: Repository<NamedEntity, SavedEntity, unknown>
} | {
    name: 'application'
    repository: Repository<NamedProjectEntity, SavedProjectEntity, ForProject>
}

// Helper types to extract the names and repository types from the entities
export type EntityNames = entities['name']
export type RepositoryOf<Type extends EntityNames> =
    entities extends (infer D) ?
        D extends entities ?
            D['name'] extends Type ? D["repository"] : never : never : never;

// The generic CRUD + list and filter with Input,Output and Filter Types
export interface Repository<
    I extends {},
    O extends {} ,
    F extends {} | unknown,
    > {
    create: (input: I) => Promise<O|void>
    get: (id: number) => Promise<O|void>
    update: (id: number, input: I) => Promise<O|void>
    list: () => Promise<O[]>
    filter: (filter: F) => Promise<O[]>
    remove: (id: number) => Promise<boolean>
}

After adding the second entity and adding more operations the implementation become a sprawling mess with no obvious ways to separate and group the function.

Before

        createProject: (project) => createNamedEntity('project', project),
        getProject: (id) => getEntity('project', id),
        listProjects: () => listNamedEntities('project'),
        deleteProject: (projectId) => deleteEntity('project', projectId),

        createApplication: (application) => createNamedEntity('application', application, 'name,projectId', (v) => [v.name, v.projectId]),
        getApplication: (id) => getEntity('application', id),
        listApplications: () => listNamedEntities<SavedProjectEntity>('application', 'id,name,projectId', RowMapApplicationNamedProjectEntity),
        listProjectApplications: (projectId) => listNamedEntities<SavedProjectEntity>('application', 'id,name,projectId', RowMapApplicationNamedProjectEntity, 'deleted = false and projectId = $1', [projectId]),
        deleteApplication: (applicationId: number) => deleteEntity('application', applicationId)

My postgres implementation already uses some helper functions to reduce the duplication of common SQL

After

const getRepository: Pick<Container, "getRepository"> = {
        getRepository: <N extends EntityNames>(name: N): RepositoryOf<N> => {
            switch (name) {
                case "project":
                    return {
                        get: (id) => getEntity('project', id),
                        create: (project: NamedEntity) => createNamedEntity('project', project),
                        filter: () => Promise.resolve([]),
                        update: (id: number, project: NamedEntity) => updateNamedEntity('project', id, project),
                        list: () => listNamedEntities('project'),
                        remove: (projectId) => deleteEntity('project', projectId),
                    }
                case "application":
                    return {
                        get: (id) => getEntity('application', id),
                        create: (application: NamedProjectEntity) => createNamedEntity('application', application, 'name,projectId', (v) => [v.name, v.projectId]),
                        filter: (filter: ForProject) => listNamedEntities<SavedProjectEntity>('application', 'id,name,projectId', RowMapApplicationNamedProjectEntity, 'deleted = false and projectId = $1', [filter.projectId]),
                        update: (id: number, project: NamedProjectEntity) => updateNamedEntity('application', id, project),
                        list: () => listNamedEntities<SavedProjectEntity>('application', 'id,name,projectId', RowMapApplicationNamedProjectEntity),
                        remove: (projectId) => deleteEntity('application', projectId),
                    };
                default:
                    return assertUnreachable(name);
            }
        }
    }

The switch ensure all entity repositories are created and provide clear delineation. There is likely opportunity to improve this future with

The other issue is that tests need to be updated with new functions even if they are not being used yet.

before

export function mockActionContainerResponse() {
    let createProject = jest.fn().mockImplementation(() => Promise.resolve());
    let listProjects = jest.fn().mockImplementation(() => Promise.resolve([]));
    let deleteProject = jest.fn().mockImplementation(() => Promise.resolve());

    let createApplication = jest.fn().mockImplementation(() => Promise.resolve());
    let listApplications = jest.fn().mockImplementation(() => Promise.resolve([]));
    let listProjectApplications = jest.fn().mockImplementation(() => Promise.resolve([]));
    let deleteApplication = jest.fn().mockImplementation(() => Promise.resolve());

    const container: Container = {
        getRepository: () => ({
            setup: jest.fn(),
            createProject,
            listProjects,
            deleteProject,
            createApplication,
            listApplications,
            listProjectApplications,
            deleteApplication
        }),
    }
    return {
        createProject,
        listProjects,
        deleteProject,

        createApplication,
        listApplications,
        listProjectApplications,
        deleteApplication,

        container,
    };
}

after

export function mockRepository() {

    const create = jest.fn().mockImplementation(() => Promise.resolve({}))
    const get = jest.fn().mockImplementation(() => Promise.resolve({}))
    const update = jest.fn().mockImplementation(() => Promise.resolve({}))
    const remove = jest.fn().mockImplementation(() => Promise.resolve(true))
    const filter = jest.fn().mockImplementation(() => Promise.resolve([]))
    const list = jest.fn().mockImplementation(() => Promise.resolve([]))

    return {
        create,
        get,
        update,
        remove,
        filter,
        list,
        repository: {
            create,
            get,
            update,
            remove,
            filter,
            list,
        }
    }

}

export function mockActionContainerResponse(repository?: Repository<any, any, any>) {
    let getRepository = jest.fn().mockImplementation(():Repository<any, any, any> => repository ? repository : mockRepository())

    const container: Container = {
        getRepository,
    }
    return {
        getRepository,
        sendClientMessage,
    };
}

Existing test won’t fail now when adding a new entity or adding a new method to the repository.

The function names are unrestrained meaning more care and attention is required to ensure the names continue to make sense.

Unit and Integration Tests

Refactoring the repository was low risk due to the high coverage of unit tests for the application core and integration tests that run against a test DB.

Changing the interface is significant though as it impacts both sides of the interface the use within core and the concrete implementations, a good one to make before the application grows too make.

    it('should store and retrieve projects', async () => {
        await repository.createProject({name: "test"})
        const projects = await repository.listProjects()
        expect(projects.length).toEqual(1)
        expect(projects[0].name).toEqual("test")
    });

became

    it('should store and retrieve projects', async () => {
        await pg.getRepository("project").create({name: "test"})
        const projects = await pg.getRepository("project").list()
        expect(projects.length).toEqual(1)
        expect(projects[0].name).toEqual("test")
    });