Skip to content

Data Mappers

Jon Freer edited this page Oct 10, 2022 · 2 revisions

What are they?

A Data Mapper is a type responsible for mapping an entity in your codebase to its persisted representation, and interfacing directly with the storage itself. For example, storing an entity in an SQL database, these are your types responsible for taking in the entity and issuing SQL statements directly, via an ORM, by invoking stored procedures, etc.

In the context of this project, a data mapper is any type satisfying the work.UnitDataMapper (unit.DataMapper) interface. Each data mapper is responsible for mapping entities of a single type, which can be dynamically evaluated using the work.TypeNameOf function.

Objects vs. functions

Objects

The work.UnitDataMapper interface assumes that your data mapper use cases include insertion, modification, and deletion. In circumstances where your codebase does have all of these use cases for a single entity type, it's recommended you create data mapper types that satisfy this interface and use the work.UnitDataMappers option, like so:

// construct a map of data mappers keyed by entity type name.
mappers := make(map[work.TypeName]work.UnitDataMapper)
mappers[work.TypeNameOf(Starship{})] = &StarshipMapper{}

// unit options.
options := []work.UnitOption {
  work.UnitDataMappers(mappers),
}

Functions

The previous approach works well if your code fits these assumptions, but often times this isn't the case. Perhaps your application doesn't delete any data, and instead only creates or modifies data. Maybe your application is smaller, and wouldn't benefit from building out the data mapping functionality as types.

This is where data mapper functions come into play.

Instead of creating a type that satisfies the work.UnitDataMapper (unit.DataMapper interface), you can pass separate functions that perform the insert, update, and delete data mapping behaviors. For example, here is a slightly modified snippet in the Getting Started page that does exactly this:

rc := Starship{ ID: 1, Name: "Razorcrest" }
t := work.TypeNameOf(rc)

options := []work.UnitOption {
  work.UnitInsertFunc(t, func(ctx context.Context, mCtx work.UnitMapperContext, entities ...interface{}) error {
    // ... insert logic goes here ...
  }),
  work.UnitUpdateFunc(t, func(ctx context.Context, mCtx work.UnitMapperContext, entities ...interface{}) error {
    // ... update logic goes here ...
  }),
  work.UnitDeleteFunc(t, func(ctx context.Context, mCtx work.UnitMapperContext, entities ...interface{}) error {
    // ... delete logic goes here ...
  }),
}

As you can see, the work.UnitInsertFunc, work.UnitUpdateFunc, and work.UnitDeleteFunc options can be used to specify these mapper functions. There is no requirement that you must specify all three for any given TypeName; feel free to only specify what your application needs.

Dealers Choice

In general, we recommend using functions over types when possible. The additional flexibility the functions provide is more forgiving for an evolving codebase, doesn't require any no-op boilerplate just to satisfy the interface, and actually can support a codebase with and without dedicated data mapper types.

That last bit is probably the most important. In situations where you have a data mapper type that has methods on it that adhere to work.UnitDataMapperFunc you can pass those methods directly as the mapper functions. As an example, here is how you could leverage mapper functions even if you have data mapper types in your codebase:

rc := Starship{ ID: 1, Name: "Razorcrest" }
t := work.TypeNameOf(rc)
m := &StarshipMapper{}

// unit options.
options := []work.UnitOption {
  work.UnitInsertFunc(t, m.Insert),
  work.UnitUpdateFunc(t, m.Update),
}

Nevertheless, both avenues are actively supported! You can even mix & match approaches if you'd like.

Clone this wiki locally