The Game Panel specification

RFC-0013: Entities

Abstract

A thin layer over the database component: an identifier type per entity, a map holding the entities loaded during one unit of work along with the data they were loaded from, and a base store that finds, saves and deletes them. Saving compares an entity against the data it was loaded from and writes only what changed. It is not an object-relational mapper: turning a row into an entity, and an entity back into columns, belongs to each store.

Motivation

Working with the database means writing the same things repeatedly: reading a row into an object, deciding whether saving that object is an insert or an update, working out which columns actually changed, and keeping two objects loaded from the same row from drifting apart.

None of that needs a full object-relational mapper. It needs identity, change detection, and somewhere to put the persistence logic that every store would otherwise repeat.

Proposal

Concepts

Term Meaning
Entity A plain object with an identity, holding data and no persistence logic.
Identifier An entity’s identity, a ULID with a type of its own per entity.
Entity map The entities loaded or saved during one unit of work, with the data each came from.
Snapshot The column data an entity was last loaded from or saved as.
Store What finds, saves and deletes entities of one type.
Soft deletable An entity that is marked deleted rather than removed.

Components

Component Responsibility
Entity Contract requiring an identifier.
EntityId Abstract identifier, one subclass per entity type.
EntityMap Holds the entities of one unit of work, with their snapshots.
EntityStore Contract for finding, saving and deleting entities.
BaseEntityStore The persistence logic every store shares.
HasTimestamps, Timestamps Contract and collection for entities carrying timestamps.
IsSoftDeletable Contract for entities marked deleted rather than removed.

Identity

EntityId is an abstract readonly identifier, and each entity type declares its own subclass, so one entity’s identifier cannot be passed where another’s is expected. An identifier holds a ULID, validated when it is constructed, and EntityId::make() generates a new one, per ADR-0011.

Because a ULID is generated in PHP, an entity has its identity before it is ever written, so inserting needs nothing back from the database to know what it just stored.

Entity requires only getId(): EntityId. An entity holds data and knows nothing about being persisted.

The entity map

EntityMap holds the entities of one unit of work, keyed by entity class and identifier, each with the snapshot it was loaded or last saved from.

Method Effect
add(Entity $entity, array $data): void Holds the entity and the data as its snapshot.
has(string $entity, string $id): bool Whether an entity of that class and identifier is held.
get(string $entity, string $id): ?Entity The entity held, if any.
changes(string $entity, string $id, array $data): array The columns in the data that differ from the snapshot, never including the identifier.
forget(string $entity, string $id): void Drops the entity and its snapshot.

Two reads of the same row therefore give the same object, and saving compares against what the row actually held rather than against what the entity was constructed with.

changes() compares each column strictly against the snapshot, so a value of a different type counts as a change, and compares a structured value, such as one stored as JSON, whole rather than field by field.

Stores

interface EntityStore
{
    public function find(EntityId $id): ?Entity;

    public function save(Entity $entity): WriteResult;

    public function delete(Entity $entity): WriteResult;
}

A concrete store is usually final and extends BaseEntityStore, which holds the persistence logic and leaves two things to the store:

Method Responsibility
hydrate(Row $row): Entity Builds an entity from a row.
dehydrate(Entity $entity): array Turns an entity into columns to write.

A store is given a connection, per RFC-0012, and the map. It decides which tables it reads and writes, since a store may well use more than one.

Stores are not entities’ business and entities are not stores’ business: nothing on an entity saves it.

Finding

find() returns the entity held in the map if it is there, and otherwise queries for it, hydrates it, holds it in the map and returns it. Nothing is found twice.

Saving

save() decides between inserting and updating by whether the map holds the entity:

After either, the entity is held in the map again with its new snapshot.

An entity constructed by hand with an identifier that already exists is therefore inserted, not updated, because nothing loaded it. Updating means loading first.

Every write a store makes carries its entity’s identifier, so the guard in RFC-0012 that refuses an unconditional write never applies to one.

Deleting

delete() removes the row, or marks the entity deleted when it is soft deletable, and forgets it from the map afterwards.

Timestamps

An entity carrying timestamps implements HasTimestamps, which exposes a Timestamps collection of named, nullable moments. The base store touches them: creating sets the created and updated moments, and updating sets the updated one.

Method Effect
get(string $name) The moment, or nothing when unset.
has(string $name) Whether it is set.
add(string $name) Sets it to now, unless it is already set.
set(string $name, $moment) Sets it to the moment given.
touch(string $name) Sets it to now, whatever it held.

Moments are held as immutable date and time objects carrying a time zone, which matches the database storing them with one, per RFC-0012.

Timestamps belongs to this design rather than to the database component, because it exists for the entity lifecycle.

Soft deleting

An entity opts in by implementing IsSoftDeletable:

Method Effect
hasBeenDeleted(): bool Whether it is marked deleted.
markDeleted($now = null): void Marks it deleted, at the moment given or now.
markRestored(): void Clears the mark.

The base store then does the rest, and a concrete store never names the column that records it:

Method Effect
find(EntityId $id) Excludes entities marked deleted.
findDeleted(EntityId $id) Finds one whether or not it is marked deleted.
delete(Entity $entity) Marks it deleted and writes that, rather than removing the row.
restore(Entity $entity) Clears the mark and writes that.
forceDelete(Entity $entity) Removes the row, whether or not the entity is soft deletable.

The column recording the deletion is the base store’s, defaulting to a deleted-at column and overridable by a store that needs another name. An entity that does not implement the contract is always removed, and the four methods above that only make sense for one that does throw for it.

Out of scope

Alternatives considered

The decision this design rests on is recorded separately, with the alternative it rejected:

A full object-relational mapper. The complexity of one is not needed, and a data mapper with hydration left to each store does what the panel wants without describing every relationship in metadata.

Returning nothing when a save finds no changes. A caller then cannot tell a save that did nothing from a save that did not happen, so an empty result is returned instead.

No other alternatives were weighed.

Backwards compatibility

Nothing breaks. No entity layer exists before this.

Open questions

Changelog

Sources