DDD.Repository.Ecto (vicander v0.1.1)

Copy Markdown View Source

Auto-generates a full Ecto repository adapter from a DDD.Repository port.

Reads aggregate field metadata and port find_by declarations to generate:

  • Embedded Ecto schema (no separate schema module needed)
  • find_by_id/1
  • save/1 — aggregate upsert + outbox event inserts in ONE transaction
  • delete/1
  • find_by_<field>/1 for every find_by on the port
  • to_domain/1 and from_domain/1

The whole aggregate is one row. Nested entities ({:list, Entity} / {:one, Entity}) are stored as jsonb and rebuilt into sealed structs on load; keys missing from old rows take the field default.

save/1 refuses structs that are not sealed (DDD.Error with code :unsealed).

Usage

defmodule MyApp.Infrastructure.OrderRepository do
  use DDD.Repository.Ecto,
    port:  MyApp.OrderRepository,
    repo:  MyApp.Repo,
    table: "orders"

  # Custom queries use schema() and repo() helpers
  def find_recent(limit \\ 10) do
    import Ecto.Query
    from(o in schema(), order_by: [desc: o.created_at], limit: ^limit)
    |> repo().all()
    |> Enum.map(&to_domain/1)
  end
end

Your own schema

To store an aggregate in a table you already have, pass the Ecto schema that maps it:

use DDD.Repository.Ecto,
  port:   MyApp.OrderRepository,
  repo:   MyApp.Repo,
  schema: MyApp.Infra.OrderRecord       # instead of `table:`; giving both is an error

Each aggregate field is read from, and written to, the schema field of the same name (through DDD.Type). For anything else override to_domain/1 and from_domain/1; the two-argument versions take the values for the fields that differ, so the rest stays by name:

def to_domain(record), do: to_domain(record, customer_id: record.cust_ref)
def from_domain(order), do: from_domain(order, cust_ref: order.customer_id)

The save is the framework's: your schema's changeset/2 is never called, and the upsert, the optimistic lock and the outbox insert stay in one transaction. The schema must have a :version field, the aggregate's identity as primary key (an :identity string column for a composite one), and DDD.Type.Json for nested entities, lists and maps (see DDD.Type.ecto_type/1). It is checked when the adapter is compiled; overriding to_domain/1 or from_domain/1 turns off the by-name checks (unmapped fields, JSON types).

You own the table: mix vic.gen.migration skips the aggregate, and DDD.Migration.schema_drift/2 / DDD.Migration.table_drift/1 report a mismatch for a test.

Migrations

mix vic.gen.migration MyApp.Order generates and diffs table migrations; mix vic.gen.outbox_migration creates the outbox table.

Summary

Functions

Ecto type for a domain field type (see DDD.Type).

What is wrong with schema as the storage of aggregate, as a list of messages (empty: fine).

Functions

ecto_type(type)

Ecto type for a domain field type (see DDD.Type).

schema_problems(aggregate, schema, arg, overridden?)

@spec schema_problems(module(), module(), {atom(), Ecto.Type.t()}, boolean()) :: [
  String.t()
]

What is wrong with schema as the storage of aggregate, as a list of messages (empty: fine).

pk is the {field, ecto_type} the aggregate's identity needs. With overridden? (you wrote to_domain/1 or from_domain/1) the by-name mapping is yours, so unmapped fields and JSON column types are not checked; the primary key and the :version column always are.