billing/GLOSSARY.md
Glossary
One meaning per word inside this context. The code, the events, the API and the
person on the call all spell it the same way.
“Payment” in three departments
Department
What it calls a payment
What it is called here
Product
The purchase went through, the customer saw the checkmark
An order event. Not here
Processing
The money is held, nothing was captured
Authorization
Finance
The money showed up in a statement two days later
Settlement
Words
Authorization. The issuer agreed to hold the amount. No money moved, and the
hold expires on its own.
Capture. The instruction to take what was held. From here on the money has
left the payer’s account.
Settlement. The money reached our account, and the provider’s statement says
so. It arrives days later, and it may not arrive at all.
Payment. One attempt to collect money for one order, with a lifecycle of its
own: authorized, captured, refunded. The word means this and nothing else.
Words that are not here
Purchase. A product event: the customer saw the checkmark. It lives with
orders, and there is nothing for it to mean here.
billing/invoice/domains/invoice/invoice.go package invoice
import (
" time "
" billing/invoice/domains/invoice/events "
" billing/invoice/domains/invoice/vo "
)
// the invoice: the aggregate root
type Invoice struct {
number Number
customer CustomerID
lines [] Line
total vo . Money
dueOn time . Time
status Status
facts [] events . Event
}
// creates the invoice and sums the lines
func Issue (
number Number ,
customer CustomerID ,
lines [] Line ,
dueOn time . Time ,
) ( * Invoice , error ) {
if len (lines) == 0 {
return nil , ErrNoLines
}
total := vo. NewMoney ( 0 )
for _, line := range lines {
total = total. Add (line. Amount ())
}
invoice := & Invoice {
number: number,
customer: customer,
// a copy of the lines: they cannot be changed from outside
lines: append ([] Line ( nil ), lines ... ),
total: total,
dueOn: dueOn,
status: StatusIssued,
}
// records the fact; sending it is not the aggregate's job
invoice.facts = append (invoice.facts, events . Issued {
Number: string (number),
Customer: string (customer),
Total: total,
})
return invoice, nil
}
// hands the collected facts to the outside
func ( i * Invoice ) Events () [] events . Event { return i.facts }
func ( i * Invoice ) Total () vo . Money { return i.total }
func ( i * Invoice ) DueOn () time . Time { return i.dueOn }
func ( i * Invoice ) Status () Status { return i.status }
billing/invoice/domains/invoice/invoice.go package invoice
import (
" time "
" billing/invoice/domains/invoice/events "
" billing/invoice/domains/invoice/vo "
)
// the invoice: the aggregate root
type Invoice struct {
number Number
customer CustomerID
lines [] Line
total vo . Money
dueOn time . Time
status Status
// the number of the state that was read from the database
version int
facts [] events . Event
}
// creates the invoice; the version stays zero — it is not in the database yet
func Issue (
number Number ,
customer CustomerID ,
lines [] Line ,
dueOn time . Time ,
) ( * Invoice , error ) {
if len (lines) == 0 {
return nil , ErrNoLines
}
total := vo. NewMoney ( 0 )
for _, line := range lines {
total = total. Add (line. Amount ())
}
invoice := & Invoice {
number: number,
customer: customer,
// a copy of the lines: they cannot be changed from outside
lines: append ([] Line ( nil ), lines ... ),
total: total,
dueOn: dueOn,
status: StatusIssued,
}
// records the fact; sending it is not the aggregate's job
invoice.facts = append (invoice.facts, events . Issued {
Number: string (number),
Customer: string (customer),
Total: total,
})
return invoice, nil
}
// hands the collected facts to the outside
func ( i * Invoice ) Events () [] events . Event { return i.facts }
func ( i * Invoice ) Total () vo . Money { return i.total }
func ( i * Invoice ) DueOn () time . Time { return i.dueOn }
func ( i * Invoice ) Status () Status { return i.status }
// returns the version; nothing outside can change it
func ( i * Invoice ) Version () int { return i.version }
billing/invoice/domains/invoice/paid.go package invoice
import " billing/invoice/domains/invoice/events "
// marks the invoice paid; paying twice is not allowed
func ( i * Invoice ) MarkPaid () error {
if i.status == StatusPaid {
return ErrAlreadyPaid
}
i.status = StatusPaid
// records the fact; sending it is not the aggregate's job
i.facts = append (i.facts, events . Paid {Number: string (i.number)})
return nil
}
billing/invoice/domains/invoice/charged.go package invoice
import (
" billing/invoice/domains/invoice/events "
" billing/invoice/domains/invoice/vo "
)
// puts the fee on the invoice; a paid invoice takes none
func ( i * Invoice ) ChargePenalty ( amount vo . Money ) error {
if i.status == StatusPaid {
return ErrAlreadyPaid
}
if amount. Equal (vo. NewMoney ( 0 )) {
return nil
}
i.lines = append (i.lines, Line {Quantity: 1 , Price: amount})
i.total = i.total. Add (amount)
// records the fact; sending it is not the aggregate's job
i.facts = append (i.facts, events . PenaltyCharged {
Number: string (i.number),
Amount: amount,
})
return nil
}
billing/invoice/domains/invoice/events/event.go package events
import " billing/invoice/domains/invoice/vo "
// a domain fact; its name travels to the bus as the message type
type Event interface {
FactName () string
}
// the invoice was issued
type Issued struct {
Number string
Customer string
Total vo . Money
}
func ( Issued ) FactName () string { return "invoice.issued" }
// the invoice was paid
type Paid struct {
Number string
}
func ( Paid ) FactName () string { return "invoice.paid" }
// the invoice was charged a late fee
type PenaltyCharged struct {
Number string
Amount vo . Money
}
func ( PenaltyCharged ) FactName () string { return "invoice.penalty_charged" }
billing/invoice/domains/invoice/calendar.go package invoice
import " time "
// the working calendar: part of the model, not a storage port
type Calendar interface {
Workdays ( from time . Time , to time . Time ) int
}
billing/invoice/domains/invoice/services/penalty/penalty.go package penalty
import (
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// the rule: an invoice, a rate and a calendar — an amount out
func Amount (
overdue * invoice . Invoice ,
now time . Time ,
perWorkday vo . Money ,
calendar invoice . Calendar ,
) vo . Money {
// the rule takes the due date from the invoice itself, not from the caller
late := calendar. Workdays (overdue. DueOn (), now)
if late <= 0 {
return vo. NewMoney ( 0 )
}
return vo. NewMoney (perWorkday. Get () * int64 (late))
}
billing/invoice/domains/invoice/repository.go package invoice
import " context "
// port: how the invoice gets into the database and comes back
type Repository interface {
ByNumber ( ctx context . Context , number Number ) ( * Invoice , error )
Save ( ctx context . Context , invoice * Invoice ) error
}
billing/invoice/domains/invoice/repository.go package invoice
import (
" context "
" errors "
)
// the invoice changed between the read and the write
var ErrConflict = errors. New ( "invoice: invoice changed since it was loaded" )
// port: how the invoice gets into the database and comes back
type Repository interface {
ByNumber ( ctx context . Context , number Number ) ( * Invoice , error )
// returns ErrConflict if the invoice was changed in the meantime
Save ( ctx context . Context , invoice * Invoice ) error
}
billing/invoice/domains/invoice/publisher.go package invoice
import (
" context "
" billing/invoice/domains/invoice/events "
)
// port: where the facts go
type Publisher interface {
Publish ( ctx context . Context , facts ... events . Event ) error
}
billing/invoice/domains/invoice/vo/money.go package vo
// the amount in minor units
type Money struct {
minor int64
}
func NewMoney ( minor int64 ) Money {
return Money {minor: minor}
}
func ( m Money ) Add ( other Money ) Money {
return Money {minor: m.minor + other.minor}
}
func ( m Money ) Equal ( other Money ) bool {
return m.minor == other.minor
}
func ( m Money ) Get () int64 {
return m.minor
}
billing/invoice/domains/invoice/rules/overdue.go package rules
import (
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// the specification contract: one question about one aggregate
type Specification interface {
IsSatisfiedBy ( inv * invoice . Invoice ) bool
}
// the predicate: is this invoice overdue?
type Overdue struct {
now time . Time
calendar invoice . Calendar
}
func NewOverdue ( now time . Time , calendar invoice . Calendar ) Overdue {
return Overdue {now: now, calendar: calendar}
}
// a paid invoice is never overdue
func ( o Overdue ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
if inv. Status () == invoice.StatusPaid {
return false
}
return o.calendar. Workdays (inv. DueOn (), o.now) > 0
}
// a second predicate: the invoice is above the limit
type OverBudget struct {
limit vo . Money
}
func NewOverBudget ( limit vo . Money ) OverBudget {
return OverBudget {limit: limit}
}
func ( b OverBudget ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
return inv. Total (). Get () > b.limit. Get ()
}
// composition: requires every predicate at once
type All struct {
specs [] Specification
}
func AllOf ( specs ... Specification ) All {
return All {specs: specs}
}
func ( a All ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
for _, spec := range a.specs {
if ! spec. IsSatisfiedBy (inv) {
return false
}
}
return true
}
billing/invoice/applications/overdue/charge.go package overdue
import (
" context "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/rules "
" billing/invoice/domains/invoice/services/penalty "
" billing/invoice/domains/invoice/vo "
)
// two ports, a calendar and a rate — all the use case needs
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
calendar invoice . Calendar
perWorkday vo . Money
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
calendar invoice . Calendar ,
perWorkday vo . Money ,
) * UseCase {
return & UseCase {
uow: uow,
invoices: invoices,
publisher: publisher,
calendar: calendar,
perWorkday: perWorkday,
}
}
// load, calculate, apply, save, tell
func ( u * UseCase ) Run ( ctx context . Context , number invoice . Number , now time . Time ) error {
return u.uow. Do (ctx, func ( ctx context . Context ) error {
overdue, err := u.invoices. ByNumber (ctx, number)
if err != nil {
return err
}
// the use case asks the rule instead of trusting the caller
if ! rules. NewOverdue (now, u.calendar). IsSatisfiedBy (overdue) {
return nil
}
// the rule computes the amount from the invoice, the rate and the calendar
amount := penalty. Amount (overdue, now, u.perWorkday, u.calendar)
// only the invoice itself can charge the fee to the invoice
if err := overdue. ChargePenalty (amount); err != nil {
return err
}
if err := u.invoices. Save (ctx, overdue); err != nil {
return err
}
return u.publisher. Publish (ctx, overdue. Events () ... )
})
}
billing/invoice/applications/issuing/issue.go package issuing
import (
" context "
" time "
" billing/invoice/domains/invoice "
)
// the dependencies are domain ports and nothing else
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// one argument — the command, and it checks its own shape
func ( u * UseCase ) Handle ( ctx context . Context , cmd IssueInvoice ) error {
if err := cmd. Validate (); err != nil {
return err
}
issued, err := invoice. Issue (cmd.Number, cmd.Customer, cmd.Lines, cmd.DueOn)
if err != nil {
return err
}
return u.uow. Do (ctx, func ( ctx context . Context ) error {
if err := u.invoices. Save (ctx, issued); err != nil {
return err
}
return u.publisher. Publish (ctx, issued. Events () ... )
})
}
billing/invoice/applications/payment/pay.go package payment
import (
" context "
" billing/invoice/domains/invoice "
)
// the dependencies are domain ports and nothing else
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// load, call the method, save, tell — as one operation
func ( u * UseCase ) Run ( ctx context . Context , number invoice . Number ) error {
return u.uow. Do (ctx, func ( ctx context . Context ) error {
paid, err := u.invoices. ByNumber (ctx, number)
if err != nil {
return err
}
if err := paid. MarkPaid (); err != nil {
return err
}
if err := u.invoices. Save (ctx, paid); err != nil {
return err
}
return u.publisher. Publish (ctx, paid. Events () ... )
})
}
billing/invoice/applications/payment/pay.go package payment
import (
" context "
" errors "
" billing/invoice/domains/invoice "
)
// how many times to try on a conflict
const attempts = 3
// the dependencies are domain ports and nothing else
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// retries the whole attempt while someone else is changing the invoice
func ( u * UseCase ) Run ( ctx context . Context , number invoice . Number ) error {
var err error
for attempt := 0 ; attempt < attempts; attempt ++ {
err = u. pay (ctx, number)
// a conflict is not a bad call: the data went stale
if errors. Is (err, invoice.ErrConflict) {
continue
}
return err
}
return err
}
// one attempt: load, apply, save, tell
func ( u * UseCase ) pay ( ctx context . Context , number invoice . Number ) error {
return u.uow. Do (ctx, func ( ctx context . Context ) error {
paid, err := u.invoices. ByNumber (ctx, number)
if err != nil {
return err
}
if err := paid. MarkPaid (); err != nil {
return err
}
if err := u.invoices. Save (ctx, paid); err != nil {
return err
}
return u.publisher. Publish (ctx, paid. Events () ... )
})
}
billing/invoice/domains/invoice/load.go package invoice
import (
" time "
" billing/invoice/domains/invoice/vo "
)
// rebuilds the invoice from what was in the database
func Load (
number Number ,
customer CustomerID ,
lines [] Line ,
total vo . Money ,
dueOn time . Time ,
status Status ,
) * Invoice {
return & Invoice {
number: number,
customer: customer,
lines: lines,
total: total,
dueOn: dueOn,
status: status,
}
}
billing/invoice/domains/invoice/load.go package invoice
import (
" time "
" billing/invoice/domains/invoice/vo "
)
// rebuilds the invoice from what was in the database
func Load (
number Number ,
customer CustomerID ,
lines [] Line ,
total vo . Money ,
dueOn time . Time ,
status Status ,
// the version arrives together with the data
version int ,
) * Invoice {
return & Invoice {
number: number,
customer: customer,
lines: lines,
total: total,
dueOn: dueOn,
status: status,
version: version,
}
}
billing/invoice/domains/invoice/uow.go package invoice
import " context "
// port: run the work as a whole or do not run it
type UnitOfWork interface {
Do ( ctx context . Context , work func ( ctx context . Context ) error ) error
}
billing/invoice/infrastructure/postgres/invoices.go package postgres
import (
" context "
" database/sql "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// the storage port implemented on Postgres
type Invoices struct {
db * sql . DB
}
func NewInvoices ( db * sql . DB ) * Invoices {
return & Invoices {db: db}
}
// loads the invoice and rebuilds the aggregate
func ( r * Invoices ) ByNumber ( ctx context . Context , number invoice . Number ) ( * invoice . Invoice , error ) {
var (
customer string
minor int64
dueOn time . Time
status string
)
err := conn (ctx, r.db).
QueryRowContext (ctx, selectInvoice, string (number)).
Scan ( & customer, & minor, & dueOn, & status)
if err != nil {
return nil , err
}
lines, err := r. lines (ctx, number)
if err != nil {
return nil , err
}
// rebuilding the aggregate is the domain's job
return invoice. Load (
number,
invoice. CustomerID (customer),
lines,
vo. NewMoney (minor),
dueOn,
invoice. Status (status),
), nil
}
// writes the invoice and its lines
func ( r * Invoices ) Save ( ctx context . Context , saved * invoice . Invoice ) error {
db := conn (ctx, r.db)
if _, err := db. ExecContext (ctx, upsertInvoice, saved); err != nil {
return err
}
_, err := db. ExecContext (ctx, replaceLines, saved)
return err
}
billing/invoice/infrastructure/postgres/invoices.go package postgres
import (
" context "
" database/sql "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// the storage port implemented on Postgres
type Invoices struct {
db * sql . DB
}
func NewInvoices ( db * sql . DB ) * Invoices {
return & Invoices {db: db}
}
// loads the invoice together with the row version
func ( r * Invoices ) ByNumber ( ctx context . Context , number invoice . Number ) ( * invoice . Invoice , error ) {
var (
customer string
minor int64
dueOn time . Time
status string
version int
)
err := conn (ctx, r.db).
QueryRowContext (ctx, selectInvoice, string (number)).
Scan ( & customer, & minor, & dueOn, & status, & version)
if err != nil {
return nil , err
}
lines, err := r. lines (ctx, number)
if err != nil {
return nil , err
}
return invoice. Load (
number,
invoice. CustomerID (customer),
lines,
vo. NewMoney (minor),
dueOn,
invoice. Status (status),
version,
), nil
}
// writes the invoice if its version in the database is still the same
func ( r * Invoices ) Save ( ctx context . Context , saved * invoice . Invoice ) error {
db := conn (ctx, r.db)
// version zero — the invoice is new, so it is inserted
if saved. Version () == 0 {
if _, err := db. ExecContext (ctx, insertInvoice, saved); err != nil {
return err
}
_, err := db. ExecContext (ctx, replaceLines, saved)
return err
}
// the version check sits inside the UPDATE itself
result, err := db. ExecContext (ctx, updateInvoice, saved, saved. Version ())
if err != nil {
return err
}
rows, err := result. RowsAffected ()
if err != nil {
return err
}
// no rows — the version in the database is a different one
if rows == 0 {
return invoice.ErrConflict
}
_, err = db. ExecContext (ctx, replaceLines, saved)
return err
}
billing/invoice/infrastructure/postgres/uow.go package postgres
import (
" context "
" database/sql "
)
type txKey struct {}
// the unit of work implemented as a Postgres transaction
type UnitOfWork struct {
db * sql . DB
}
func NewUnitOfWork ( db * sql . DB ) * UnitOfWork {
return & UnitOfWork {db: db}
}
// all of the work inside one transaction
func ( u * UnitOfWork ) Do ( ctx context . Context , work func ( ctx context . Context ) error ) error {
tx, err := u.db. BeginTx (ctx, nil )
if err != nil {
return err
}
if err := work (context. WithValue (ctx, txKey {}, tx)); err != nil {
// an error inside — rolled back as a whole
_ = tx. Rollback ()
return err
}
return tx. Commit ()
}
// the transaction from ctx, if the operation has already begun
func conn ( ctx context . Context , db * sql . DB ) interface {
ExecContext ( context . Context , string , ... any ) ( sql . Result , error )
QueryRowContext ( context . Context , string , ... any ) * sql . Row
} {
if tx, ok := ctx. Value ( txKey {}).( * sql . Tx ); ok {
return tx
}
return db
}
billing/invoice/infrastructure/postgres/outbox.go package postgres
import (
" context "
" database/sql "
" encoding/json "
" billing/invoice/domains/invoice/events "
)
// publishing implemented as rows in the database
type Outbox struct {
db * sql . DB
}
func NewOutbox ( db * sql . DB ) * Outbox {
return & Outbox {db: db}
}
// writes the facts into the same transaction
func ( o * Outbox ) Publish ( ctx context . Context , facts ... events . Event ) error {
db := conn (ctx, o.db)
for _, event := range facts {
body, err := json. Marshal (event)
if err != nil {
return err
}
// the fact name and the message body
if _, err := db. ExecContext (ctx, insertOutbox, event. FactName (), body); err != nil {
return err
}
}
return nil
}
billing/invoice/applications/issuing/transport/http/dto/issue.go package dto
import (
" errors "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// the request body: what the data crosses the boundary in
type IssueRequest struct {
Number string `json:"number"`
Customer string `json:"customer"`
DueOn time . Time `json:"due_on"`
Lines [] Line `json:"lines"`
}
// an invoice line the way it is sent
type Line struct {
AmountMinor int64 `json:"amount_minor"`
}
var ErrEmptyNumber = errors. New ( "dto: number is empty" )
// the shape of the request is checked, not the rules of the domain
func ( r IssueRequest ) Validate () error {
if r.Number == "" {
return ErrEmptyNumber
}
return nil
}
// what the use case needs from the request, already in domain types
type Issue struct {
Number invoice . Number
Customer invoice . CustomerID
Lines [] invoice . Line
DueOn time . Time
}
// translates the request body into domain types
func ( r IssueRequest ) ToDomain () Issue {
lines := make ([] invoice . Line , 0 , len (r.Lines))
for _, item := range r.Lines {
lines = append (lines, invoice. NewLine (vo. NewMoney (item.AmountMinor)))
}
return Issue {
Number: invoice. Number (r.Number),
Customer: invoice. CustomerID (r.Customer),
Lines: lines,
DueOn: r.DueOn,
}
}
billing/invoice/applications/issuing/transport/http/handler.go package http
import (
" encoding/json "
" net/http "
" billing/invoice/applications/issuing "
" billing/invoice/applications/issuing/transport/http/dto "
)
// the handler owns the use case and nothing else
type Handler struct {
issue * issuing . UseCase
}
func NewHandler ( issue * issuing . UseCase ) * Handler {
return & Handler {issue: issue}
}
// POST /invoices: parse, translate, call
func ( h * Handler ) Issue ( w http . ResponseWriter , r * http . Request ) {
var body dto . IssueRequest
// the body does not parse — that is the client's error
if err := json. NewDecoder (r.Body). Decode ( & body); err != nil {
http. Error (w, "malformed json" , http.StatusBadRequest)
return
}
if err := body. Validate (); err != nil {
http. Error (w, err. Error (), http.StatusUnprocessableEntity)
return
}
// translating into domain types is the dto package's job
args := body. ToDomain ()
// the transport builds the command — it travels on, not a handful of arguments
cmd := issuing . IssueInvoice {
Number: args.Number,
Customer: args.Customer,
Lines: args.Lines,
DueOn: args.DueOn,
}
// the use case gets domain entities, not a request
err := h.issue. Handle (r. Context (), cmd)
if err != nil {
// a separate function turns a domain error into a status code
fail (w, err)
return
}
// 201 and an empty body: the client sent the number itself
w. WriteHeader (http.StatusCreated)
}
billing/invoice/applications/issuing/transport/http/errors.go package http
import (
" errors "
" log/slog "
" net/http "
" billing/invoice/domains/invoice "
)
// turns a domain error into a status code
func fail ( w http . ResponseWriter , err error ) {
switch {
// a broken domain rule is the request's error, not the server's
case errors. Is (err, invoice.ErrNoLines):
http. Error (w, "invoice has no lines" , http.StatusUnprocessableEntity)
case errors. Is (err, invoice.ErrAlreadyPaid):
http. Error (w, "invoice is already paid" , http.StatusConflict)
default :
// the cause stays in the log, the code goes out
slog. Error ( "issue invoice" , "error" , err)
http. Error (w, "internal error" , http.StatusInternalServerError)
}
}
billing/invoice/applications/issuing/transport/http/routes.go package http
import " net/http "
// the module declares the address of its own handler
func ( h * Handler ) Register ( mux * http . ServeMux ) {
mux. HandleFunc ( "POST /invoices" , h.Issue)
}
billing/invoice/cmd/api/main.go package main
import (
" log "
" net/http "
" os "
_ " github.com/lib/pq "
)
// the assembly moved into a generated function
func main () {
handler, err := initHandler (os. Getenv ( "DATABASE_URL" ))
if err != nil {
log. Fatal (err)
}
mux := http. NewServeMux ()
handler. Register (mux)
log. Fatal (http. ListenAndServe ( ":8080" , mux))
}
billing/invoice/cmd/api/wire.go //go:build wireinject
package main
import (
" database/sql "
" github.com/google/wire "
" billing/invoice/domains/invoice "
" billing/invoice/infrastructure/postgres "
" billing/invoice/applications/issuing "
issuinghttp " billing/invoice/applications/issuing/transport/http "
)
// the set of adapters: one for all applications of the service
var adapters = wire. NewSet (
postgres.NewUnitOfWork,
postgres.NewInvoices,
postgres.NewOutbox,
// which implementation stands behind the port
wire. Bind ( new ( invoice . UnitOfWork ), new ( * postgres . UnitOfWork )),
wire. Bind ( new ( invoice . Repository ), new ( * postgres . Invoices )),
wire. Bind ( new ( invoice . Publisher ), new ( * postgres . Outbox )),
)
// what to assemble — stated by the result type
func initHandler ( dsn string ) ( * issuinghttp . Handler , error ) {
wire. Build (openDB, adapters, issuing.New, issuinghttp.NewHandler)
// a stub body: the real one is written by the generator
return nil , nil
}
func openDB ( dsn string ) ( * sql . DB , error ) {
return sql. Open ( "postgres" , dsn)
}
billing/invoice/applications/issuing/command.go package issuing
import (
" errors "
" time "
" billing/invoice/domains/invoice "
)
// the command: what to do and with what data
type IssueInvoice struct {
Number invoice . Number
Customer invoice . CustomerID
Lines [] invoice . Line
DueOn time . Time
}
var ErrNoNumber = errors. New ( "issuing: invoice number is empty" )
// the shape is checked here, not the domain rules
func ( c IssueInvoice ) Validate () error {
if c.Number == "" {
return ErrNoNumber
}
return nil
}
billing/invoice/applications/views/card.go package views
import (
" context "
" billing/invoice/domains/invoice "
)
// an invoice card for the screen, not an aggregate
type Card struct {
Number string
Total int64
Status string
Overdue bool
}
// the query: what is asked for, named as a type
type CardQuery struct {
Number invoice . Number
}
// the read port: hands out a ready card
type Cards interface {
Card ( ctx context . Context , query CardQuery ) ( Card , error )
}
billing/invoice/infrastructure/postgres/cards.go package postgres
import (
" context "
" database/sql "
" billing/invoice/applications/views "
" billing/invoice/domains/invoice "
)
// the query returns exactly what the screen shows
const selectCard = `
SELECT number,
total_minor,
status,
due_on < now() AND status <> 'paid' AS overdue
FROM invoices
WHERE number = $1`
// the read port implemented: one query, no aggregate
type Cards struct {
db * sql . DB
}
func NewCards ( db * sql . DB ) * Cards {
return & Cards {db: db}
}
// reads an invoice card by its number
func ( c * Cards ) Card ( ctx context . Context , query views . CardQuery ) ( views . Card , error ) {
var card views . Card
// the columns land straight in the projection
err := c.db. QueryRowContext (ctx, selectCard, string (query.Number)).
Scan ( & card.Number, & card.Total, & card.Status, & card.Overdue)
if err != nil {
return views . Card {}, err
}
return card, nil
}