diff --git a/.wrangler/cache/pages.json b/.wrangler/cache/pages.json new file mode 100644 index 0000000..8881842 --- /dev/null +++ b/.wrangler/cache/pages.json @@ -0,0 +1,4 @@ +{ + "account_id": "9f52ed810ad09a589c0045ad0e552873", + "project_name": "factos" +} \ No newline at end of file diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..a36bd32 --- /dev/null +++ b/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "9f52ed810ad09a589c0045ad0e552873", + "name": "Renata.amutio@gmail.com's Account" + } +} \ No newline at end of file diff --git a/backends/factos_cf_workers/src/factos/factos_cf_workers.gleam b/backends/factos_cf_workers/src/factos/factos_cf_workers.gleam index 99ccb43..b152fa7 100644 --- a/backends/factos_cf_workers/src/factos/factos_cf_workers.gleam +++ b/backends/factos_cf_workers/src/factos/factos_cf_workers.gleam @@ -9,6 +9,10 @@ //// conditional `insert ... select ... returning` statement for appends. That keeps //// the append condition and writes in the same SQLite statement, which is the //// atomic boundary D1 exposes through prepared statements. +//// +//// Side-effect outbox rows (derived from the codec's `side_effects` function) are +//// inserted atomically alongside events via `d1.batch`, preserving the invariant +//// that side effects are never lost after a successful event append. import cf_workers/d1 import factos @@ -53,17 +57,41 @@ pub type StoredEvent { ) } -pub type EventCodec(event, decode_error) { +/// An outbox row for a registered side effect. +/// +/// The backend inserts these atomically alongside the events that produced them. +/// `event_type` identifies the kind of side effect. `stream` is the event stream +/// name. `payload` is application-owned opaque data, typically JSON with the +/// arguments the side-effect handler needs. +pub type OutboxRow { + OutboxRow(event_type: String, stream: String, payload: String) +} + +pub type EventCodec(event, state, decode_error) { /// Application-owned D1 event codec. + /// + /// `side_effects` maps each domain event to the outbox rows it triggers. + /// It receives the event, the stream name, and the **pre‑event** decision state + /// so that contextual data (e.g. customer email) can be captured for the + /// side-effect handler without a second read. EventCodec( encode: fn(event) -> Proposed(event), decode: fn(StoredEvent) -> Result(factos.Decoded(event), decode_error), + side_effects: fn(event, String, state) -> List(OutboxRow), ) } pub type Append { /// Result of a successful append. - Append(current_revision: Int, position: factos.SequencePosition) + /// + /// `outbox_ids` contains the auto-generated IDs of side-effect outbox rows that + /// were inserted atomically with the events. The caller may use these IDs to + /// enqueue asynchronous processing of those side effects. + Append( + current_revision: Int, + position: factos.SequencePosition, + outbox_ids: List(Int), + ) } pub type Error(domain_error, decode_error) { @@ -119,12 +147,27 @@ pub fn migrate(database: d1.Database) -> Promise(Result(Nil, Error(_, _))) { on factos_events(stream, revision) ", )) - execute_migration( + use _ <- promise.try_await(execute_migration( database, " create index if not exists factos_events_position on factos_events(position) ", + )) + execute_migration( + database, + " + create table if not exists event_outbox ( + id integer primary key autoincrement, + stream text not null, + event_type text not null, + payload text not null, + status text not null default 'pending', + error text, + created_at integer not null default (unixepoch()), + processed_at integer + ) + ", ) } @@ -146,7 +189,7 @@ pub fn read_context( database: d1.Database, query query: factos.Query, decider decider: factos.Decider(command, state, event, domain_error), - codec codec: EventCodec(event, decode_error), + codec codec: EventCodec(event, state, decode_error), ) -> Promise( Result(factos.Context(event, state), Error(domain_error, decode_error)), ) { @@ -170,12 +213,12 @@ pub fn read_context( } /// Run a full context-first read-decide-append command flow. -pub fn dispatch_context( +pub fn dispatch_with_context( database: d1.Database, stream stream_name: String, query query: factos.Query, decider decider: factos.Decider(command, state, event, domain_error), - codec codec: EventCodec(event, decode_error), + codec codec: EventCodec(event, state, decode_error), command command: command, ) -> Promise(Result(Append, Error(domain_error, decode_error))) { use context <- promise.try_await(read_context( @@ -188,12 +231,14 @@ pub fn dispatch_context( Error(error) -> promise.resolve(Error(DomainError(error))) Ok(pair) -> { let #(context, events) = pair + let outbox_rows = generate_outbox_rows(events, stream_name, context.state, codec) append_with_condition( database, stream_name, events, codec, context.append_condition, + outbox_rows, ) } } @@ -204,7 +249,7 @@ pub fn load_stream( database: d1.Database, stream stream_name: String, decider decider: factos.Decider(command, state, event, domain_error), - codec codec: EventCodec(event, decode_error), + codec codec: EventCodec(event, state, decode_error), ) -> Promise( Result(factos.LoadedStream(event, state), Error(domain_error, decode_error)), ) { @@ -226,11 +271,11 @@ pub fn load_stream( } /// Run a stream-based read-decide-append command flow. -pub fn dispatch_stream( +pub fn dispatch( database: d1.Database, stream stream_name: String, decider decider: factos.Decider(command, state, event, domain_error), - codec codec: EventCodec(event, decode_error), + codec codec: EventCodec(event, state, decode_error), command command: command, ) -> Promise(Result(Append, Error(domain_error, decode_error))) { use loaded <- promise.try_await(load_stream( @@ -242,27 +287,90 @@ pub fn dispatch_stream( let factos.Decider(_, decide, _) = decider case decide(loaded.state, command) { Error(error) -> promise.resolve(Error(DomainError(error))) - Ok(events) -> + Ok(events) -> { + let outbox_rows = generate_outbox_rows(events, stream_name, loaded.state, codec) append_stream_events( database, stream_name, events, codec, loaded.revision, + outbox_rows, ) + } } } +/// Run a stream-based read-decide-append command flow and return the loaded +/// state alongside the append result. This avoids a second read when the caller +/// needs both the appended metadata and the decision state (e.g., customer +/// details for a side effect). +pub fn dispatch_with_state( + database: d1.Database, + stream stream_name: String, + decider decider: factos.Decider(command, state, event, domain_error), + codec codec: EventCodec(event, state, decode_error), + command command: command, +) -> Promise( + Result(#(Append, factos.LoadedStream(event, state)), Error( + domain_error, + decode_error, + )), +) { + use loaded <- promise.try_await(load_stream( + database, + stream: stream_name, + decider: decider, + codec: codec, + )) + let factos.Decider(_, decide, _) = decider + let events = decide(loaded.state, command) + + use append <- promise.try_await( + case events { + Ok(events) -> { + let outbox_rows = generate_outbox_rows(events, stream_name, loaded.state, codec) + append_stream_events( + database, + stream_name, + events, + codec, + loaded.revision, + outbox_rows, + ) + |> promise.map(fn(result) { + result |> result.map(fn(append) { #(append, loaded) }) + }) + } + Error(error) -> Error(DomainError(error)) |> promise.resolve + }, + ) + + Ok(append) |> promise.resolve +} + +fn generate_outbox_rows( + events: List(event), + stream_name: String, + state: state, + codec: EventCodec(event, state, decode_error), +) -> List(OutboxRow) { + let EventCodec(_, _, side_effects) = codec + events + |> list.flat_map(fn(event) { side_effects(event, stream_name, state) }) +} + fn append_with_condition( database: d1.Database, stream_name: String, events: List(event), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), condition: factos.AppendCondition, + outbox_rows: List(OutboxRow), ) -> Promise(Result(Append, Error(domain_error, decode_error))) { case condition { factos.NoAppendCondition -> - append_events(database, stream_name, events, codec, CurrentStream) + append_events(database, stream_name, events, codec, CurrentStream, outbox_rows) factos.FailIfEventsMatch(query, after) -> append_events( database, @@ -270,6 +378,7 @@ fn append_with_condition( events, codec, ContextCondition(query, after), + outbox_rows, ) } } @@ -278,18 +387,27 @@ fn append_stream_events( database: d1.Database, stream_name: String, events: List(event), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), expected: factos.Revision, + outbox_rows: List(OutboxRow), ) -> Promise(Result(Append, Error(domain_error, decode_error))) { - append_events(database, stream_name, events, codec, ExpectedStream(expected)) + append_events( + database, + stream_name, + events, + codec, + ExpectedStream(expected), + outbox_rows, + ) } fn append_events( database: d1.Database, stream_name: String, events: List(event), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), mode: AppendMode, + outbox_rows: List(OutboxRow), ) -> Promise(Result(Append, Error(domain_error, decode_error))) { case events { [] -> @@ -297,36 +415,75 @@ fn append_events( |> promise.map(fn(result) { result |> result.map(fn(revision) { - Append(current_revision: revision, position: factos.NoPosition) + Append(current_revision: revision, position: factos.NoPosition, outbox_ids: []) }) }) [_, ..] -> { let #(sql, values) = append_sql(stream_name, events, codec, mode) - d1.prepare(database, sql) - |> d1.bind(values) - |> d1.raw - |> promise.map(fn(result) { - use rows <- result.try(result |> result.map_error(StoreError)) - use appended <- result.try(decode_append_rows(rows)) - case list.length(appended) == list.length(events) { - True -> { - let #(position, revision) = last_append_row(appended) - Ok(Append( - current_revision: revision, - position: factos.SequencePosition(position), - )) - } - False -> Error(AppendConditionFailed(append_condition_for(mode))) + let event_statement = + d1.prepare(database, sql) |> d1.bind(values) + + case outbox_rows { + [] -> + d1.batch(database, [event_statement]) + |> decode_batch_result(events, mode) + [_, ..] -> { + let #(outbox_sql, outbox_values) = outbox_insert_sql(outbox_rows) + let outbox_statement = + d1.prepare(database, outbox_sql) |> d1.bind(outbox_values) + d1.batch(database, [event_statement, outbox_statement]) + |> decode_batch_result(events, mode) } - }) + } } } } +fn decode_batch_result( + batch_result: Promise(Result(array.Array(d1.RunResult), String)), + events: List(event), + mode: AppendMode, +) -> Promise(Result(Append, Error(domain_error, decode_error))) { + batch_result + |> promise.map(fn(result) { + use run_results <- result.try(result |> result.map_error(StoreError)) + use first_result <- result.try( + array.get(run_results, 0) + |> result.replace_error(StoreError("no event insert result in batch")), + ) + let d1.RunResult(success: True, results: event_rows, ..) = first_result + + use appended <- result.try(decode_append_rows(event_rows)) + case list.length(appended) == list.length(events) { + True -> { + let outbox_ids = case array.length(run_results) { + 1 -> Ok([]) + _ -> { + use outbox_result <- result.try( + array.get(run_results, 1) + |> result.replace_error(StoreError("no outbox insert result in batch")), + ) + let d1.RunResult(success: True, results: outbox_rows, ..) = outbox_result + decode_outbox_ids(outbox_rows) + } + } + use outbox_ids <- result.try(outbox_ids) + let #(position, revision) = last_append_row(appended) + Ok(Append( + current_revision: revision, + position: factos.SequencePosition(position), + outbox_ids: outbox_ids, + )) + } + False -> Error(AppendConditionFailed(append_condition_for(mode))) + } + }) +} + fn read_matching_events( database: d1.Database, query: factos.Query, - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), ) -> Promise( Result(List(factos.Recorded(event)), Error(domain_error, decode_error)), ) { @@ -345,7 +502,7 @@ fn read_matching_events( fn read_stream_events( database: d1.Database, stream_name: String, - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), ) -> Promise( Result(List(factos.Recorded(event)), Error(domain_error, decode_error)), ) { @@ -382,7 +539,7 @@ fn current_revision( fn decode_rows( rows: array.Array(array.Array(Dynamic)), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), ) -> Result(List(factos.Recorded(event)), Error(domain_error, decode_error)) { rows |> array.to_list @@ -391,10 +548,10 @@ fn decode_rows( fn decode_row( row: array.Array(Dynamic), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), ) -> Result(factos.Recorded(event), Error(domain_error, decode_error)) { use stored <- result.try(decode_stored_event(row)) - let EventCodec(_, decode_event) = codec + let EventCodec(_, decode_event, _) = codec use decoded <- result.try( decode_event(stored) |> result.map_error(DecodeError), ) @@ -441,17 +598,34 @@ fn decode_stored_event( } fn decode_append_rows( - rows: array.Array(array.Array(Dynamic)), + rows: array.Array(Dynamic), ) -> Result(List(#(Int, Int)), Error(domain_error, decode_error)) { rows |> array.to_list |> list.try_map(fn(row) { - use position <- result.try(decode_int_field(row, 0)) - use revision <- result.try(decode_int_field(row, 1)) + use position <- result.try( + decode.run(row, decode.field("position", decode.int)) + |> result.map_error(RowDecodeError), + ) + use revision <- result.try( + decode.run(row, decode.field("revision", decode.int)) + |> result.map_error(RowDecodeError), + ) Ok(#(position, revision)) }) } +fn decode_outbox_ids( + rows: array.Array(Dynamic), +) -> Result(List(Int), Error(domain_error, decode_error)) { + rows + |> array.to_list + |> list.try_map(fn(row) { + decode.run(row, decode.field("id", decode.int)) + |> result.map_error(RowDecodeError) + }) +} + fn decode_int_field( row: array.Array(Dynamic), index: Int, @@ -479,7 +653,7 @@ fn decode_string_field( fn append_sql( stream_name: String, events: List(event), - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), mode: AppendMode, ) -> #(String, List(String)) { let rows = @@ -500,11 +674,11 @@ fn append_sql( fn append_select_sql( stream_name: String, event: event, - codec: EventCodec(event, decode_error), + codec: EventCodec(event, state, decode_error), mode: AppendMode, index: Int, ) -> #(String, List(String)) { - let EventCodec(encode, _) = codec + let EventCodec(encode, _, _) = codec let Proposed(id, _, type_, version, tags, metadata, data) = encode(event) let base_values = [ id, @@ -526,6 +700,25 @@ fn append_select_sql( ) } +fn outbox_insert_sql( + outbox_rows: List(OutboxRow), +) -> #(String, List(String)) { + let placeholders = + list.repeat("(?, ?, ?)", list.length(outbox_rows)) + |> string.join(with: ", ") + let values = + outbox_rows + |> list.flat_map(fn(OutboxRow(event_type, stream, payload)) { + [stream, event_type, payload] + }) + #( + "insert into event_outbox (stream, event_type, payload) values " + <> placeholders + <> " returning id", + values, + ) +} + fn append_condition_sql( stream_name: String, mode: AppendMode, diff --git a/backends/factos_cf_workers/test/factos_cf_workers_test.gleam b/backends/factos_cf_workers/test/factos_cf_workers_test.gleam index 32b2301..ab71595 100644 --- a/backends/factos_cf_workers/test/factos_cf_workers_test.gleam +++ b/backends/factos_cf_workers/test/factos_cf_workers_test.gleam @@ -57,7 +57,7 @@ pub fn dispatch_stream_appends_and_loads_events_test() -> Promise(Nil) { } } - use append_result <- promise.await(backend.dispatch_stream( + use append_result <- promise.await(backend.dispatch( test_database.database, stream: "reservation-renata", decider: reservation_decider(), @@ -132,7 +132,7 @@ pub fn dispatch_context_rejects_changed_context_test() -> Promise(Nil) { ]), ]) - use first_result <- promise.await(backend.dispatch_context( + use first_result <- promise.await(backend.dispatch_with_context( test_database.database, stream: "reservation-renata", query: query, @@ -155,7 +155,7 @@ pub fn dispatch_context_rejects_changed_context_test() -> Promise(Nil) { } } - use second_result <- promise.await(backend.dispatch_context( + use second_result <- promise.await(backend.dispatch_with_context( test_database.database, stream: "reservation-renata-duplicate", query: query, diff --git a/docs/domain-driven-design.md b/docs/domain-driven-design.md index eb555f0..ba86c53 100644 --- a/docs/domain-driven-design.md +++ b/docs/domain-driven-design.md @@ -3,99 +3,209 @@ Domain-Driven Design, often shortened to DDD, is an approach to building software around the business domain it serves. -The main idea is simple: the most important parts of the code should speak the -same language as the people who understand the business. If the business talks -about registering users, reserving tickets, approving invoices, opening accounts, -or cancelling orders, the code should make those concepts visible instead of -hiding them behind generic database or framework terms. +The central idea is that the most important code in a system should reflect the +real business concepts, rules, and language of the people who understand that +business. If the business talks about approving invoices, reserving seats, +opening accounts, settling payments, or cancelling bookings, those ideas should +be visible in the code. -DDD is not a library, framework, folder structure, or architecture pattern. It is -a way of designing software so that the model in the code matches the model used -by domain experts. +DDD is not a framework, library, diagramming technique, or folder structure. It is +a way of designing software so that the model in the code and the model used by +domain experts stay close to each other. + +## Why DDD Exists + +Many systems start as technical models rather than domain models. The code is +organized around tables, controllers, endpoints, jobs, forms, or generic data +objects. That can work for simple CRUD applications, but it becomes painful when +the business rules are complex. + +When the business is complex, bugs often come from misunderstandings: + +1. Developers use a word differently from domain experts. +2. One part of the system assumes a rule that another part does not know about. +3. Important business states are represented as vague strings or booleans. +4. Rules are duplicated across handlers, database triggers, and background jobs. +5. Technical names hide the actual reason a change is allowed or rejected. + +DDD tries to reduce this gap. It encourages teams to discover the domain language, +model the important rules directly, and keep technical details from overwhelming +the business model. ## The Domain The domain is the area of knowledge the software is about. -For an accounting system, the domain includes invoices, payments, credits, -balances, tax rules, and financial periods. For a ticketing system, it includes -events, tickets, reservations, customers, seat maps, and sales rules. +For an accounting system, the domain may include invoices, payments, credits, +taxes, ledgers, balances, and accounting periods. For a ticketing system, it may +include venues, seats, shows, reservations, ticket sales, refunds, and capacity +rules. For a logistics system, it may include shipments, routes, warehouses, +carriers, customs declarations, and delivery promises. + +The domain is not the database. It is not the UI. It is not the HTTP API. Those +things are implementation details around the domain. + +DDD asks: what are the important concepts and rules of this business, and how can +the code express them clearly? + +## Domain Experts + +Domain experts are the people who understand the business problem. They may be +accountants, support agents, warehouse operators, doctors, insurance analysts, +lawyers, product managers, or experienced users. + +They may not know how to write code, but they know the rules the system must +respect. DDD treats their language and distinctions as design input, not just as +requirements that are translated once and forgotten. -DDD asks you to put this domain knowledge at the center of the design. Technical -details still matter, but they should support the domain model rather than -dominate it. +For example, a domain expert might explain that an order is not simply +"cancelled". It may be cancelled before payment, cancelled after payment but +before shipping, or returned after fulfilment. Those distinctions matter because +they lead to different business consequences. + +Good DDD keeps asking for these distinctions until the software model can express +them. ## Ubiquitous Language -A key DDD practice is building a ubiquitous language: a shared vocabulary used by -developers, domain experts, product people, and the code itself. +A ubiquitous language is a shared vocabulary used by developers, domain experts, +product people, documentation, tests, and code. + +The goal is to avoid translation layers such as: + +1. The business says "reservation", but the code says `TemporaryOrder`. +2. The business says "settled payment", but the database says `status = 4`. +3. The business says "invoice is overdue", but the code says `is_bad = true`. -If the business says a username can be reserved, registered, or already taken, -the code should use names like these: +These translations create confusion. When the code uses the same words as the +business, conversations become more precise and mistakes are easier to see. + +For example, this model communicates business meaning: ```gleam -pub type Command { - RegisterUser(username: String) +pub type InvoiceStatus { + Draft + Issued + Paid + Overdue + Void } +``` -pub type Event { - UsernameReserved(username: String) - UserRegistered(username: String) -} +This model hides it: -pub type DomainError { - UsernameAlreadyTaken +```gleam +pub type Invoice { + Invoice(status: Int) } ``` -These names are not just labels. They document the business rules and make -conversations easier. A developer and a domain expert can discuss -`UsernameAlreadyTaken` without translating from implementation details such as -rows, tables, HTTP requests, or storage errors. +Both can be stored in a database, but only the first one explains the business +states in the code itself. + +## Models Are Purposeful + +In DDD, a model is not a perfect copy of the real world. It is a useful +simplification for a particular purpose. + +A delivery application does not need to model every physical detail of a parcel. +It may only need weight, dimensions, destination, customs category, and delivery +promise. A medical scheduling system may care about appointments, clinicians, +rooms, and patient eligibility, but not the internal details of billing. + +A good model includes the distinctions needed to make correct business decisions. +It leaves out detail that does not matter for those decisions. ## Bounded Contexts -Large systems rarely have one perfect model for everything. The same word can -mean different things in different parts of a business. +Large organizations rarely have one universal model. The same word can mean +different things in different parts of the business. + +For example, `Customer` can mean: + +1. the person receiving support, +2. the legal entity that receives an invoice, +3. the buyer placing an order, +4. the account holder with contractual obligations. + +Trying to force all of these meanings into one universal `Customer` model often +creates confusion. Each team adds fields for its own needs, and the model becomes +large, vague, and hard to change. + +DDD uses bounded contexts to avoid this. A bounded context defines where a model +and its language are valid. + +Inside the billing context, `Customer` might mean the billable legal entity. +Inside the support context, `Customer` might mean the person asking for help. Both +models can be correct because they serve different purposes. -For example, a `Customer` in billing may mean the legal entity that receives an -invoice. A `Customer` in support may mean the person who opened a ticket. Both -models can be correct inside their own part of the system. +## Context Boundaries Matter -DDD calls these boundaries bounded contexts. A bounded context defines where a -particular model and language are valid. +Bounded contexts are not only about code organization. They are about meaning. -In Gleam, that often means keeping types and functions focused on one context at -a time. A billing command, billing event, and billing error type should not have -to carry every detail from support, inventory, or fulfilment. +Crossing a boundary often requires translation. A support case may refer to a +customer email address, while billing may require a billing account id. A shipping +context may know about parcels and labels, while sales may know about orders and +line items. -## Entities, Values, and Rules +Keeping these models separate prevents accidental coupling. It also lets each +part of the system evolve with the language and rules of its own business area. -DDD distinguishes between different kinds of domain concepts. +## Entities -Entities have identity over time. An order, account, or user may change while -remaining the same conceptual thing. +An entity is a domain concept with identity over time. -Value objects are defined by their contents. A money amount, email address, -date range, or seat number is usually meaningful because of its value rather than -because of a separate identity. +For example: -Business rules describe what is allowed. A username may be registered only if it -is available. A ticket may be sold only if capacity remains. An invoice may be -paid only once. +1. an order, +2. a user account, +3. a bank account, +4. a shipment, +5. a support case. -Gleam custom types are useful here because they let you model these concepts -directly and avoid many invalid states. +An entity can change while remaining the same conceptual thing. An order may move +from `Draft` to `Placed` to `Shipped`. A bank account balance may change. A +support case may be reassigned. The identity is what lets the business say it is +still the same order, account, or case. + +Entities should not become bags of data with every possible operation attached. +Their purpose is to protect the rules that belong to their identity and lifecycle. + +## Value Objects + +A value object is a domain concept defined by its contents rather than by a +separate identity. + +Examples include: + +1. money amount, +2. email address, +3. date range, +4. seat number, +5. geographic coordinate, +6. percentage discount. + +Two value objects with the same contents are usually interchangeable. If two +prices are both `10 EUR`, they represent the same value. If two date ranges cover +the same dates, they represent the same range. + +Value objects are useful because they give names and validation rules to concepts +that would otherwise be primitive strings, integers, or floats. ```gleam -pub type UsernameState { - UsernameAvailable - UsernameTaken +pub type Money { + Money(amount_in_cents: Int, currency: Currency) +} + +pub type Currency { + Eur + Usd + Gbp } ``` -This is clearer than passing around a generic boolean whose meaning can be -forgotten or inverted. +This is clearer than passing separate integers and strings through the system and +hoping every function interprets them correctly. ## Invariants @@ -104,53 +214,146 @@ change. Examples include: -1. A username cannot be registered twice. -2. A paid invoice cannot be paid again. -3. A ticket sale cannot exceed venue capacity. -4. A bank account cannot be closed while it has a non-zero balance. +1. a username cannot be registered twice, +2. a paid invoice cannot be paid again, +3. a ticket sale cannot exceed venue capacity, +4. a bank account cannot be closed while it has a non-zero balance, +5. a shipment cannot be marked delivered before it has been dispatched. + +Invariants are central to DDD because they define what the model must protect. +They are different from ordinary validation. + +Validation might check that an email address has a plausible shape. An invariant +checks whether a change is allowed by the current business state. + +Each command should make clear which facts are needed to protect the invariant it +cares about. The consistency boundary can then follow the rule being checked +rather than a fixed object hierarchy. + +## Commands, Events, and State -DDD encourages making these rules explicit in the domain model. In Gleam, a rule -can be represented as a pure function that receives the relevant state and either -accepts the command or returns a domain error. +DDD does not require event sourcing, but many DDD models benefit from separating +intent, facts, and state. + +A command represents intent. It asks the system to do something: ```gleam -pub fn decide(state: UsernameState, command: Command) -> Result(List(Event), DomainError) { - case state, command { - UsernameAvailable, RegisterUser(username) -> Ok([UserRegistered(username)]) - UsernameTaken, RegisterUser(_) -> Error(UsernameAlreadyTaken) - } +pub type Command { + IssueInvoice(customer_id: String, amount: Money) + PayInvoice(invoice_id: String) + VoidInvoice(invoice_id: String, reason: String) } ``` -The function describes the business rule without mentioning databases, JSON, -queues, web handlers, or transactions. +An event represents something the business has accepted as true: + +```gleam +pub type Event { + InvoiceIssued(invoice_id: String, customer_id: String, amount: Money) + InvoicePaid(invoice_id: String) + InvoiceVoided(invoice_id: String, reason: String) +} +``` + +State represents what the model needs to know to make a decision: + +```gleam +pub type InvoiceState { + NoInvoice + OpenInvoice(amount: Money) + PaidInvoice + VoidedInvoice +} +``` + +Keeping these concepts separate makes the model easier to reason about. A command +can be rejected. An event is already accepted. State is a derived view used for a +decision. + +## Domain Errors + +Domain errors explain why a business operation is not allowed. + +They should use domain language rather than infrastructure language. + +```gleam +pub type PaymentError { + InvoiceDoesNotExist + InvoiceAlreadyPaid + InvoiceWasVoided +} +``` + +This is more useful than returning generic errors such as `BadRequest`, +`DatabaseError`, or `InvalidState` from the domain model. Technical errors can +still exist at the application or infrastructure boundary, but business rejection +should be described in business terms. + +## Services and Side Effects + +DDD separates domain rules from technical side effects. + +The domain model should decide whether something is allowed. It should not usually +send emails, call payment providers, write files, publish messages, or make HTTP +requests directly. + +Those effects belong in application or infrastructure code that coordinates the +use case. The domain should expose the business decision clearly enough that the +outer code knows what happened and what effects are needed. + +For example: + +1. The domain accepts `PayInvoice` and produces `InvoicePaid`. +2. Application code stores that fact. +3. A handler reacts by sending a receipt email. +4. Another handler updates a reporting view. + +The receipt email is important, but sending it is not the same as deciding +whether the invoice may be paid. + +## Strategic and Tactical DDD + +DDD is often described in two parts: strategic design and tactical design. + +Strategic design is about understanding the larger system: + +1. What are the bounded contexts? +2. Which teams own which models? +3. Which contexts need to integrate? +4. Where is the core business complexity? +5. Which parts can be simpler supporting systems? -## How Factos Applies DDD +Tactical design is about modelling inside a context: -Factos supports DDD by keeping the domain model in the application. +1. What are the entities? +2. What are the value objects? +3. What invariants must be protected? +4. What commands can be accepted? +5. What domain errors can happen? +6. Which facts are needed to protect each invariant? -Your application defines the commands, events, states, and errors using its own -business language. Factos provides small primitives for turning those definitions -into pure decision components and for connecting those decisions to event storage. +Both matter. Tactical patterns without strategic boundaries can produce a large, +overcomplicated model. Strategic diagrams without concrete code can fail to +protect the real rules. -A Factos `Decider` is made from: +## How Gleam Helps -1. an initial state, -2. a function that decides whether a command is allowed, -3. a function that evolves state from accepted facts. +Gleam is a good fit for DDD because it encourages explicit modelling with small, +concrete types. -This keeps the important business rule easy to read, easy to test, and separate -from infrastructure. +Custom types can name business states directly. Pattern matching makes business +cases visible. Exhaustive checks help when the model changes. `Result` makes +business rejection explicit. Pure functions keep domain decisions separate from +infrastructure. -## Why This Fits Gleam +DDD is mostly about clarity. Gleam helps make that clarity executable. -Gleam works well for DDD because it encourages explicit, concrete modelling: +## How Factos Fits -1. Custom types name the concepts in the domain. -2. Pattern matching makes business cases visible. -3. Exhaustive checks help when the model changes. -4. `Result` makes domain rejection explicit. -5. Pure functions keep rules independent from infrastructure. +Factos is not required to practice DDD. It is one small set of primitives for +applications that want to model domain decisions from accepted facts. -DDD is mostly about clarity. Gleam helps by making that clarity part of the type -system instead of leaving it only in comments or diagrams. +With Factos, the application still owns the domain language. The application +defines its commands, events, states, errors, and business rules. Factos provides +supporting types for pure decisions and event-backed consistency, while storage +and side effects remain outside the domain model.