An object-to-object mapper generator for Go that can 'scale'
sesame is a go mapper. Japanese often abbreviate this kind of terms as 'goma'. Goma means the sesame in Japanese.
Multitier architectures like good-old 3tier architecture, Clean architecture, Hexagonal architecture etc have similar objects in each layers.
It is a hard work that you must write so many bolierplate code to map these objects. There are some kind of libraries(an object-to-object mapper) that simplify this job using reflections.
Object-to-object mappers that use reflections are very easy to use, but these are difficult to 'scale' .
- Hard to debug: Objects in the real world, rather than examples, are often very large. In reflection-based libraries, it can be quite hard to debug if some fields are not mapped correctly.
- Performance: Go's reflection is fast enough for most usecases. But, yes, large applications that selects multitier architectures often have very large objects. Many a little makes a mickle.
sesame generates object-to-object mappers source codes that DO NOT use reflections.
This project is in very early stage. Any kind of feedbacks are wellcome.
- Fast : sesame generates object-to-object mappers source codes that DO NOT use reflections in mapping logics.
- Easy to debug : If some fields are not mapped correctly, you just look a generated mapper source codes.
- Flexible : sesame provides various way to map objects.
- By name
- Simple field to field mapping
- Field to nesting field mapping like
TodoModel.UserID -> TodoEntity.User.ID
. - Embedded struct mapping
- By type
- By helper function that is written in Go
- By conterter that is written in Go
- By name
- Zero 3rd-party dependencies at runtime : sesame generates codes that depend only standard libraries.
- Scalable :
- Fast, Easy to debug and flexible.
- Mapping configurations can be separated into multiple files.
- You do not have to edit over 10000 lines of a single YAML file that has 1000 mapping definitions.
- Your project must be a Go module.
Get a binary from releases .
sesame requires Go 1.20+.
$ go install github.com/yuin/sesame/cmd/sesame@latest
$ go get -u github.com/yuin/sesame
- Install the
sesame
command. - Add
github.com/yuin/sesame
to yourgo.mod
file. - Create a configuration file(s).
- Run the
sesame
command. - Create a
Mappers
object in your code. - (Optional) Add helpers and converters.
- Get a mapper from
Mappers
. - Map objects by the mapper.
See tests for examples.
sesame consists of 3 kind of objects: Mapper, Converter, and Helper .
Mappers map struct fields from A to B and B to A. Mappers only work when source object is not nil. Mappers always overwrite destination fields.
Mapper has methods like the following:
type TodoMapper interface {
TodoModelToTodo(pkg00000.Context, *pkg00001.TodoModel, *pkg00002.Todo) error
TodoToTodoModel(pkg00000.Context, *pkg00002.Todo, *pkg00001.TodoModel) error
}
Source and destination arguments are always struct pointers.
Converters convert a value from A to B and B to A. Converters work even if source object is nil. Converters always create a new value unlike Mappers that always overwrite existing values.
Converter has methods like the following:
type TimeStringConverter struct {
}
func (m *TimeStringConverter) StringToTime(ctx context.Context, source *string) (*time.Time, error) {
if source == nil {
return nil, nil
}
t, err := time.Parse(time.RFC3339, source)
if err != nil {
return nil, err
}
return &t, nil
}
func (m *TimeStringConverter) TimeToString(ctx context.Context, source *time.Time) (string, bool, error) {
if source == nil {
return "", true, nil
}
return source.Format(time.RFC3339), false, nil
}
A source argument is a pointer or an interface. Returned value types will be a:
- primitive types(i.e.
string
,int
, ...array
):value, prefernil, error
- example: returns
string, bool, error
- if
prefernil
is true and a mapping destination is a pointer, the destination will be nil.
- if
- example: returns
- slices, maps:
value
,error
- example: returns
[]int, error
- example: returns
- interface:
interface
,error
- others:
pointer, error
Note that a source argument can be nil.
- Mappers are used for mapping struct fields. source and destination arguments are always struct pointers. A destination object must be initialized before mapping.
- Converters are used for converting any values. source and destination argument can be any types. Converters always create a new value.
If same type combinations are used in converters and mappers, converters will be used.
- Mapper id must end with "Mapper" like "TodoMapper"
- Converter id must end with "Converter" like "TimeStringConverter"
- Helper id must be "${MAPPER_ID}Helper"
- Mapper function name must be "XxxToYyy"
- Converter function name must be "XxxToYyy"
sesame uses mapping configuration files written in YAML.
${YOUR_GO_MODULE_ROOT}/sesame.yml
:
mappers: # configurations for a mapper collection
package: mapper # package name for generated mapper collection
destination: ./mapper/mappers_gen.go # destination for generation
nil-map: nil # how are nil collections mapped
nil-slice: nil # a value should be one of 'nil', 'empty' (default: nil)
mappings: # configurations for object-to-object mappings
- name: TodoMapper # name of the mapper
id: mymappers.TodoMappers # id of the mapper. This must be unique within all mappers.
# if id is not set, name will be used as id.
package: mapper # package name for generated mapper
destination: ./mapper/todo_mapper_gen.go # definition for generation
bidirectional: true # generates a-to-b and b-to-a mapping if true(default: false)
a-to-b: ModelToEntity # mapping function name(default: `{AName}To{BName}`)
b-to-a: EntityToModel # mapping function name(default: `{BName}To{AName}`)
a: # mapping operand A
package: ./model # package path for this operand
name: TodoModel # struct name of this operand
b: # mapping operand B
package: ./domain
name: Todo
explicit-only: false # sesame maps same names automatically if false(default: false)
allow-unmapped:false # sesame fails with unmapped fields if false(default: false)
# This value is ignored if `explicit-only' is set true.
ignore-case: false # sesame ignores field name cases if true(default: false)
nil-map: nil # how nil collections are mapped
nil-slice: nil # a default value is inherited from mappers
fields: # relationships between A fields and B fields
- a: Done # you can define nested mappings like UserID
b: Finished # you can define mappings for embedded structs by '*'
- a: UserID #
b: User.ID #
- a: Address
- b: Address
uses: AddressConverter # instead of global one, you can use a converter for this field
#
- a: IntList # if a field is a collection, you can define `uses` or `uses-for-elements`.
b: StringList # with `uses` sesame uses the converter for the whole collection.
uses-for-elements: StringIntConverter # with `uses-for-elements` sesame uses the converter for each element.
#
ignores: # ignores fields in operand X
- a: ValidateOnly
- b: User
_includes: # includes separated configuration files
- ./*/**/*_sesame.yml
And now, you can generate source codes just run sesame
command in ${YOUR_GO_MODULE_ROOT}
.
Generated mappers are added as globals. Generated mappers will use global mappers if target objects have fields that any global mappers can map/convert to other types.
This configuration will generate the codes like the following:
./mapper/todo_mapper_gen.go
:
package mapper
import (
pkg00000 "context"
pkg00003 "time"
pkg00002 "example.com/testmod/domain"
pkg00001 "example.com/testmod/model"
)
type TodoMapperHelper interface {
TodoModelToTodo(pkg00000.Context, *pkg00001.TodoModel, *pkg00002.Todo) error
TodoToTodoModel(pkg00000.Context, *pkg00002.Todo, *pkg00001.TodoModel) error
}
type TodoMapper interface {
TodoModelToTodo(pkg00000.Context, *pkg00001.TodoModel, *pkg00002.Todo) error
TodoToTodoModel(pkg00000.Context, *pkg00002.Todo, *pkg00001.TodoModel) error
}
// ... (TodoMapper default implementation)
sesame generates a mapper collection into the mappers.destination
.
Mapping codes look like the following:
-
Create new Mappers object as a singleton object. The Mappers object is a groutine-safe.
mappers := mapper.NewMappers() // Creates new `Mappers` object with generated mappers mappers.Add("TimeToStringConverter", &TimeStringConverter{}) // Add converter mappers.Add("TodoMapperHelper", &todoMapperHelper{}) // Add helpers
-
Get a mapper and call it for mapping.
obj, err := mappers.Get("TodoMapper") // Get mapper by its name if err != nil { t.Fatal(err) } todoMapper, _ := obj.(TodoMapper) var entity Todo err := todoMapper.ModelToEntity(ctx, model, &entity)
sesame.Get
is a helper object that provides type-safeGet
method.todoMapper, err := sesame.Get[TodoMapper](mappers)
By default, sesame can map following types:
- Same types
- Castable types(i.e.
int -> int64
,type MyType int <-> int
) map
,slice
andarray
For others, you can write and register converters.
Example: string <-> time.Time
mapper
type TimeStringConverter struct {
}
func (m *TimeStringConverter) StringToTime(ctx context.Context, source *string) (*time.Time, error) {
if source == nil {
return nil, nil
}
t, err := time.Parse(time.RFC3339, source)
if err != nil {
return nil, err
}
return &t, nil
}
func (m *TimeStringConverter) TimeToString(ctx context.Context, source *time.Time) (string, bool, error) {
if source == nil {
return "", true, nil
}
return source.Format(time.RFC3339), false, nil
}
func AddTimeToStringConverter(mappers Mappers) {
stringTime := &TimeStringConverter{}
mappers.Add("TimeStringConverter", stringTime) // add as globals
// add as no-globals. `uses` or `uses-for-elements` in the configuration file can use this converter.
// mappers.Add("TimeStringConverter", stringTime, sesame.WithNoGlobals())
}
You can define helper functions for more complex mappings.
type todoMapperHelper struct {
}
var _ TodoMapperHelper = &todoMapperHelper{} // TodoMapperHelper interface is generated by sesame
func (h *todoMapperHelper) ModelToEntity(ctx context.Context, source *model.TodoModel, dest *domain.Todo) error {
if source.ValidateOnly {
dest.Attributes["ValidateOnly"] = []string{"true"}
}
return nil
}
func (h *todoMapperHelper) EntityToModel(ctx context.Context, source *domain.Todo, dest *model.TodoModel) error {
if _, ok := source.Attributes["ValidateOnly"]; ok {
dest.ValidateOnly = true
}
return nil
}
and register it as {MAPPER_ID}Helper
:
mappers.Add("TodoMapperHelper", &todoMapperHelper{})
Helpers will be called at the end of the generated mapping implementations.
AddFactory
method allows you to define a factory function that returns a mapper object.
interfaces:
type MyMapper interface {
// ...
}
sesame.AddFactory[MyMapper](mappers, "MyMapper", func(m sesame.MapperGetter) (MyMapper, error) {
// you can get other mappers and converts from m
return &myMapper{}, nil
})
structs:
type MyMapper struct {
// ...
}
sesame.AddFactory[*MyMapper](mappers, "MyMapper", func(m sesame.MapperGetter) (*MyMapper, error) {
return &MyMapper{}, nil
})
You can add factories using reflections instead of sesame.*
type-safe functions:
// MyMapper is an interface, so you must use `Elem()`
mappers.AddFactory("MyMapper", reflect.TypeOf((*MyMapper)(nil)).Elem(), func(m sesame.MapperGetter) (any, error) {
return &myMapper{}, nil
})
2nd argument of AddFactory
must be a reflect.Type
of a mapper interface or a reflect.Type
of a mapper struct pointer.
Note that Go can not get interface type from nil interface value. You must use a interface pointer and its Elem()
.
Package relations with generated Go files are:
- Generated
Mappers
depends on all generatedMapper
s - Generated
Mapper
s depends on mapping targets
Examples (1):
/
+-- mapper/
| +-- mappers_gen.go : generated `Mappers`
| +-- mapper.go : add hand-written mappers, converters and helpers
+-- todo/ : contains `base` elements like `struct Todo{base.Entity}`
| +-- todo_mapper_gen.go : generated `Mapper`
| +-- todo_model.go : model definition
| +-- todo_entity.go : entity definition
| +-- todo_usecase.go : business logic uses a `Mapper`
+-- base/
| +-- base_mapper_gen.go : generated `Mapper`
| +-- base_model.go : model definition
| +-- base_entity.go : entity definition
+ cmd/main/main.go : create `Mappers`, `Mapper` and `TodoUsecase`
flowchart LR
main -- NewMappers --> mapper
main -- NewTodoUseCase(<br>mappers.Get(TodoMapper)) --> todo
mapper -- NewTodoMapper --> todo
mapper -- NewBaseMapper --> base
todo --> base
Examples (2):
/
+-- mapper/
| +-- mappers_gen.go : generated `Mappers`
| +-- mapper.go : add hand-written mappers, converters and helpers
| +-- todo_mapper_gen.go : generated `Mapper`
| +-- base_mapper_gen.go : generated `Mapper`
+-- model/
| +-- todo_model.go : model definition
| +-- base_model.go : model definition
+-- domain/
| +-- todo_entity.go : entity definition
| +-- base_entity.go : entity definition
+-- usecase/
| +-- todo_usecase.go : business logic uses a `Mapper`
+ cmd/main/main.go : uses `Mappers`, `Mapper` and `TodoUsecase`
flowchart LR
main -- NewMappers --> mapper
main -- NewTodoUsecase(<br>mappers.Get(TodoMapper)) --> usecase
usecase --> mapper
usecase --> model
usecase --> domain
mapper -- NewTodoMapper<br>NewBaseMapper --> model
mapper -- NewTodoMapper<br>NewBaseMapper --> domain
mappers := domain_mappers.NewMappers()
err := mappers.Merge(grpc_mappers.NewMappers())
// Now mappers have all mappers defined in domain_mappers and grpc_mappers
// If there are objects with the same id, grpc_mappers' objects are used.
// If there are globals for same types, grpc_mappers' objects are used.
Note that merging must be done before any Get
calls.
BTC: 1NEDSyUmo4SMTDP83JJQSWi1MvQUGGNMZB
MIT
Yusuke Inuzuka