3

Suppose I have a domain entity representing a person. (Examples in TypeScript)

class Person {
    constructor(public name: string) {}
}

Now, because other parts of the domain will need to reference the Person entity from outside the aggregate, I need to expose the Person's ID in the database.

class Person {
    constructor(public id: number, public name: string) {}
}

Now, because id is Non Nullable, I can't create new instances freely in the domain layer:

const p = new Person(undefined, 'Patrick');
error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'number'.

Well, then I have to make the id property possibly undefined - or null if you're in another language? Not sure how this problem translates to other type systems.

class Person {
    constructor(public id: number | undefined, public name: string) {}
}

const p = new Person(undefined, 'Patrick');

But now, I'll have the ID being nullable at places I'm sure it's not going to happen:

async function doSomething(): Promise<number> {
    const p = await personRepository.getPersonById(id);
    // let's say for some reason I'll use the ID
    return p.id + 500;
    error TS2532: Object is possibly 'undefined'.
}

For every property that I expect to always be there but might not, which in this example is the ID but might also include creation dates and other database managed stuff, I need to protect that path of execution:

async function doSomething(): Promise<number> {
    const p = await personRepository.getPersonById(id);
    // let's say for some reason I'll use the ID
    if (p.id === undefined) throw 'Should not happen';
    return p.id + 500;
}

This is adding a lot of cruft to our codebase.

So what is the best course of action here? Attempt to not expose the ID on the domain (seems hard)? Don't make id nullable and provide a fake that the repository will treat differently (seems smelly)? Strengthen the id type from number to something else?

  • 4
    This is a common problem. AFAIK the only reasonable approaches are (1)giving up on perfect type safety and using nullable IDs, or (2) clearly modelling a Person as being stored in the database, so that a person creation request would be modelled separately. I.e. person = repository.create(name) not repository.save(new Person({id: null, name})). A third option: use UUIDs instead of database-assigned autoincrement IDs. – amon Feb 12 at 22:37
  • I've also seen using -1 as the equivalent of an "unset" identifier. I used to think that idea wasn't so bad until I ran across an actual application that had negative IDs in the database. That is certainly atypical, but just goes to show that it does happen. – Berin Loritsch Feb 13 at 0:14
  • I've never used typescript, could you make id a property with a getter that does the undefined check and throws to avoid the error? – IllusiveBrian Feb 13 at 14:25
  • @IllusiveBrian yes, that's an option we're now considering as well – villasv Feb 13 at 14:42
4

We solved this issue by separating actual business data from the database related things. Let's take the example of a person.

export interface Person {
    name: string;
    age: number;
    favoriteColor: string;
}

Now we can wrap that interface (or class) with a more generic one, which holds all database related things.

export interface ModifiableEntity<T> {
    id: string;
    createdAt: Date;
    createdBy: string;
    modifiedAt?: Date;
    modifiedBy?: string;
    entity: T;
}

Like this you can nicely decouple data (age, name etc.) from database metadata.

When reading from the backend we receive something like this:

[
    {
        id: '1',
        createdAt: xxx,
        createdBy: 'Me',
        entity: {
            name: 'Test Person 1',
            age: 47,
            favoriteColor: 'Blue',
        }
    },
    {
        id: '2',
        createdAt: xxx,
        createdBy: 'You',
        entity: {
            name: 'Test Person 2',
            age: 11,
            favoriteColor: 'Red',
        }
    }
]

When creating a person on the backend, you might call the PUT endpoint /person. When you want to update a person you call the POST endpoint /person/{id}. The ID is part of the url and not of the payload.

Also, while working with a draft person, you might use a helper interface, like Optional<Person> to allow for incomplete person objects during creation.

1

You could make a generic DbEntity type that has an id, and keep your domain types free of database specific stuff. Like so:

type DbEntity<T> = T & { id: number };

class Person {    
    constructor(public name: string) {}
}

interface PersonRepository { 
    getPersonById(id: number): DbEntity<Person>;
    saveNewPerson(person: Person): DbEntity<Person>;
    updatePerson(person: DbEntity<Person>): void;
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.