Courier Flow is a Go application for assigning each store's delivery to a courier. It uses hexagonal architecture and four DDD bounded contexts: Order, Courier, Delivery, and Dispatch. Assignment can be automatic or manual.
The current runtime uses hardcoded fixtures and in-memory repositories. There is no database connection. Data and generated IDs reset when the process restarts. This guide describes the behavior implemented in the source today.
- Run and configure
- Architecture and ownership
- Shared identity and location
- Order domain
- Courier domain
- Delivery domain
- Dispatch domain
- Application services
- Transactions and in-memory data
- HTTP API
- Verification and current limits
Use Go 1.26.2, as declared in go.mod, and run commands from the project root:
go run ./cmd/apiThe server defaults to 0.0.0.0:8080. An optional .env file can override it:
COURIER_APP_ENVIRONMENT=development
COURIER_HTTP_HOST=0.0.0.0
COURIER_HTTP_PORT=8000| Setting | Default | Effect |
|---|---|---|
COURIER_APP_ENVIRONMENT |
development |
production selects Gin release mode. |
COURIER_HTTP_HOST |
0.0.0.0 |
Address on which the HTTP server listens. |
COURIER_HTTP_PORT |
8080 |
Port on which the HTTP server listens. |
Configuration precedence is process environment → .env → defaults.
The loader reads .env from the working directory. Restart the process after
changing its configuration. With the example above, the health URL is
http://localhost:8000/health.
Source: config.go.
| Bounded context | Business responsibility | Aggregate root |
|---|---|---|
| Order | The details and readiness of one store's portion of an order. | OrderStore |
| Courier | Courier availability, position, capabilities, and reserved capacity. | Courier |
| Delivery | The execution and tracking of one store delivery by one courier. | Delivery |
| Dispatch | Eligibility, selection, and the recorded assignment decision. | Assignment |
An aggregate root is the entry point for changing its owned state. It enforces rules before applying changes. An entity has an identity within that model. A value object is defined by its contents, such as a geographic location.
HTTP request
│
▼
Handler Parses bodies/path parameters and writes HTTP responses
│
▼
Application service Coordinates aggregates through ports and a UOW
│
├── Domain models Enforce business rules and state transitions
└── Outbound ports Repository, routing, clock, and transaction contracts
▲
│ implemented by
Infrastructure In-memory repositories, routing adapter, clock, UOW
The dependency container wires the implementations. Route files only connect paths and HTTP methods to handlers.
cmd/api/ Application entry point
internal/app/order/domain/ Store aggregate and owned entities
internal/app/order/ports/ Order-store repository contract
internal/app/courier/domain/ Courier aggregate and rules
internal/app/courier/ports/ Courier repository contract
internal/app/delivery/{domain,ports,service,handler}/
internal/app/dispatch/{domain,ports,service,handler}/
internal/infrastructure/memory/ Fixtures, repositories, UOW, routing, clock
internal/infrastructure/httpserver/ Gin setup, routes, health handler
internal/infrastructure/container/ Dependency wiring
internal/infrastructure/config/ Environment and .env loading
internal/shared/{ddd,geo,uow}/ Shared primitives and UOW contract
Domain packages do not import other bounded contexts or infrastructure libraries.
Application services translate between contexts. For example, dispatch constructs
a Candidate from a courier and constructs delivery stops from order details.
Repositories operate on aggregate roots; customer, item, and vendor entities are
saved as part of their owning OrderStore.
flowchart LR
O["OrderStore aggregate"] --> OE["Order entity"]
OE --> C["Customer entity"]
O --> V["Vendor entity"]
V --> I["Item entities"]
O --> H["OrderStatus entries"]
D["Delivery aggregate"] -. OrderStoreID .-> O
D -. CourierID .-> CR["Courier aggregate"]
CR -. ActiveDeliveryIDs .-> D
A["Assignment aggregate"] -. OrderStoreID .-> O
A -. CourierID .-> CR
A -. DeliveryID .-> D
Solid arrows mean ownership inside an aggregate. Dotted arrows mean references by ID between aggregates. A delivery holds address/location snapshots and IDs; it does not embed the mutable courier or order-store aggregate.
| API or concept | Meaning |
|---|---|
ddd.ID |
String identity. ddd.NewID() generates a ULID. |
ID() |
Returns an aggregate's or entity's internal ID. |
Props() |
Returns its current properties. Domain wrappers protect owned collections with defensive copies. |
Version() |
Available on OrderStore, Courier, and Delivery; increments on successful aggregate updates. |
Clone() |
Copies an aggregate for repository/transaction isolation. Domain methods preserve isolation when changing collections. |
The shared generic aggregate/entity types have update mechanisms, but domain
wrappers keep those fields private. Call business methods such as AddItems()
or ReserveDelivery() instead of changing properties directly.
Versions are currently change counters. The memory adapter's UOW provides concurrency protection; repository saves do not implement version comparisons.
Internal IDs differ from the external identifiers in order properties:
| Identifier | Example | Purpose |
|---|---|---|
OrderStore.ID() |
Generated ULID string | Used by dispatch and delivery references. |
OrderStoreProps.OrderStoreId |
1001 |
External ID of a store's portion of an order. |
OrderProps.OrderId |
demo-order-1 |
External order identity shared by sibling stores. |
VendorProps.VendorId |
101 |
External vendor identity; used by AddItems. |
Source: location.go.
Order and Courier expose aliases to the shared geo.Location type.
Field in LocationProps |
Type | Rule |
|---|---|---|
Latitude |
float64 |
Finite value in [-90, 90]. |
Longitude |
float64 |
Finite value in [-180, 180]. |
NewLocation() validates coordinates and creates an immutable location.
Validate() also distinguishes a missing location from a constructed one:
Location{} is missing, while a constructed (0, 0) location is valid.
Value() returns coordinates, Equal() compares values, and DistanceKM()
calculates Haversine straight-line distance in kilometers. Callers must supply
valid locations when calculating distances.
The Order context owns what must be delivered for one store, including its vendor, items, recipient, requirements, and lifecycle.
Sources: order-store.model.go, order-store.props.go.
One OrderStore contains one vendor. A customer order involving multiple vendors
is represented by separate store aggregates sharing the same OrderId:
OrderId = "order-123"
├── OrderStore A → Bakery → Delivery A → Courier X
└── OrderStore B → Market → Delivery B → Courier Y
Each store has independent status and assignment. The current Order entity is a
snapshot owned by each store, so there is no parent aggregate containing all stores
and no automatic synchronization of customer/total changes across sibling stores.
Field in OrderStoreProps |
Type | Meaning and rules |
|---|---|---|
OrderStoreId |
uint |
Required external store-order ID; must be nonzero. The memory repository rejects duplicates across aggregate IDs. |
City |
string |
Required nonblank city. The vendor must have exactly the same city. |
VehicleNeeded |
VehicleType |
"", bike, or car. Dispatch requires a car only for car; the other values allow either vehicle. |
IsCash |
bool |
Payment metadata; does not itself affect eligibility. |
RequiresPOS |
bool |
Requires a courier with a POS device during dispatch. |
PreparationTime |
time.Time |
Required expected preparation timestamp. It does not automatically mark the store ready or schedule dispatch. |
Order |
Order |
Owned order entity; can be absent while constructing the draft. |
Vendor |
Vendor |
Owned vendor entity; can be absent while constructing the draft. |
Status |
Status |
Current store lifecycle status. |
History |
[]OrderStatus |
Chronological history of successful edits and transitions. |
NewOrderStore(CreateOrderStoreProps) takes only the first six fields. It generates
an internal ID, sets create_order, and records the initial history entry. It does
not require the entire object graph at creation. ValidateOrderStore() checks the
basic draft fields; MarkReady() checks that the draft is complete.
| Behavior | Preconditions and effect |
|---|---|
AddOrder(OrderProps) |
Draft only. Creates and attaches one order; rejects a second order. |
AddCustomer(CustomerProps) |
Draft only, after AddOrder. Creates and attaches the customer; replaces an existing customer if called again. |
AddVendor(VendorProps) |
Draft only. Requires a matching city and no existing vendor. Creates and attaches the vendor. |
AddItems(vendorID, []ItemProps) |
Draft only. Requires a nonempty item list and the attached vendor's external VendorId. Validates all items before committing the edit. Appends items without merging duplicate titles. |
MarkReady(at) |
Draft only. Requires a valid order, customer, vendor, and at least one item; changes status to order_is_ready. |
Assign(at) |
Requires order_is_ready; changes status to assigned. |
PickUp(at) |
Requires assigned; changes status to picked_up. |
Complete(at) |
Requires picked_up; changes status to delivered. |
Cancel(at) |
Allowed from draft, ready, or assigned; changes status to cancelled. |
“Draft” means create_order or update_order. Every successful Add* call changes
status to update_order and appends history. Failed edits leave the aggregate
unchanged. Once ready, its contents cannot be edited through these methods.
Assign() changes the store lifecycle only. Use a dispatch application service to
also create a delivery and reserve courier capacity. Once a delivery exists, use
DeliveryService.Transition() for pickup, completion, or cancellation so the
related aggregates change together.
Sources: order.model.go, order.props.go.
| Field | Type | Meaning and rules |
|---|---|---|
OrderId |
string |
Required nonblank external order ID. Connects sibling store aggregates. |
Total |
uint |
Supplied order amount. Zero is allowed; no currency or unit is modeled. |
Customer |
Customer |
Recipient details. Optional in a draft order, required before the store becomes ready. |
NewOrder() generates an entity ID and calls ValidateOrder(). If a customer is
provided, it is validated. The amount is not calculated from items, and the code
does not split or reconcile totals across stores. Validate() additionally rejects
an uninitialized entity. Changes are made through the owning OrderStore.
Sources: customer.model.go, customer.props.go.
| Field | Type | Meaning and rules |
|---|---|---|
Name |
string |
Recipient name; must be nonblank. |
Mobile |
string |
Contact number; trimmed length must be at least 10. No country-specific or digit-only validation is implemented. |
AddressText |
string |
Required nonblank delivery address. |
Location |
Location |
Valid destination coordinates. |
NewCustomer() and ValidateCustomer() enforce these fields. Validate() also
requires an initialized entity ID. This is a recipient snapshot for the store,
not an independently persisted customer account. Dispatch copies its address and
location into the delivery's DropOff.
Sources: vendor.model.go, vendor.props.go.
| Field | Type | Meaning and rules |
|---|---|---|
VendorId |
uint |
Required nonzero external vendor ID. |
Title |
string |
Required nonblank store name. |
AddressText |
string |
Required nonblank pickup address. |
City |
string |
Required nonblank city; checked against the store when attached. |
Location |
Location |
Valid pickup coordinates. |
Items |
[]Item |
Items supplied by this vendor for this store order. |
NewVendor() validates details and any supplied items and copies the item slice.
An empty item list is valid during draft construction. MarkReady() requires at
least one item. Dispatch copies this vendor's address and location into Pickup.
Sources: item.model.go, item.props.go.
| Field | Type | Meaning and rules |
|---|---|---|
Title |
string |
Required nonblank item description. |
Count |
int |
Quantity; must be greater than zero. |
Unit |
string |
Required nonblank unit, such as piece. |
NewItem() generates an entity ID and calls ValidateItem(). Items have no product
catalog ID, price, weight, or volume in the current model. Add them through
OrderStore.AddItems() to preserve aggregate validation and history.
Source: order-status.model.go.
| Field | Type | Meaning and rules |
|---|---|---|
Status |
Status |
One of the statuses below. |
OccurredAt |
time.Time |
Required timestamp of the edit or transition. |
NewOrderStatus() creates an immutable history entry with its own entity ID.
ValidateOrderStatus() checks the status enum and timestamp. Transition legality
and chronological ordering are enforced by OrderStore, not by an isolated entry.
| Current status | Allowed next status | Trigger |
|---|---|---|
create_order |
update_order, cancelled |
Draft edit or cancellation. |
update_order |
update_order, order_is_ready, cancelled |
Another edit, readiness, or cancellation. |
order_is_ready |
assigned, cancelled |
Assignment or cancellation. |
assigned |
picked_up, cancelled |
Pickup or cancellation. |
picked_up |
delivered |
Completion. |
delivered, cancelled |
None | Terminal states. |
A new store cannot become ready without adding its children, which already records
update_order. History timestamps may be equal but must not go backwards. There
is no reopen, post-pickup cancellation, or reassignment operation.
Sources: courier.model.go, courier.props.go.
The Courier aggregate owns a courier's availability and capacity reservations.
It does not contain order details, route plans, or delivery history.
Field in CourierProps |
Type | Meaning and rules |
|---|---|---|
Name |
string |
Required nonblank display name. |
Mobile |
string |
Required contact number; trimmed length must be at least 10. |
Avatar |
string |
Optional profile image reference; no format validation. |
City |
string |
Required nonblank city pool. |
HasPOSDevice |
bool |
Whether the courier carries POS. |
Status |
CourierStatus |
offline, online, or suspended. |
VehicleType |
VehicleType |
Required bike or car. |
Location |
Location |
Latest known position; missing immediately after creation. |
LocationUpdatedAt |
time.Time |
Timestamp of the latest position. |
ActiveTripLimit |
int |
Positive maximum number of reserved deliveries. |
ActiveDeliveryIDs |
[]ddd.ID |
Unique, nonempty delivery references currently occupying capacity. |
NewCourier(CreateCourierProps) accepts profile, city, capabilities, status,
vehicle, and capacity limit. An omitted status becomes offline; a zero limit
becomes 3. A negative limit is rejected. Location and reservations are added
through behavior after construction. POS is taken from the supplied properties.
| Behavior | Rules and effect |
|---|---|
ValidateCourier(props) |
Checks required profile fields, status/vehicle enums, positive capacity, reservation count, and unique nonempty reservation IDs. |
UpdateLocation(location, at) |
Requires a valid location and nonzero timestamp no earlier than the previous position; replaces the latest position. |
ChangeStatus(status) |
Accepts any valid courier status. Existing reservations remain when going offline or being suspended. |
SetPOSDevice(has) |
Updates the POS capability. |
SetActiveTripLimit(limit) |
Requires a positive limit at least as large as the current reservation count. |
ActiveTrips() |
Returns len(ActiveDeliveryIDs); there is no separately editable count. |
Available() |
True when online, with a valid location, and below the capacity limit. |
ReserveDelivery(id) |
Requires availability and a nonempty, unreserved delivery ID; occupies one slot. |
ReleaseDelivery(id) |
Requires an existing reservation; removes it and frees one slot, even if the courier is offline. |
Availability does not include freshness, city matching, vehicle requirements, POS
requirements, or route limits. Those are dispatch policy checks. In particular,
UpdateLocation() permits a future timestamp, but dispatch excludes a courier
whose latest position is in the future.
Reservation methods validate the courier's own state. The application service
ensures that reservations correspond to actual deliveries. Use
DeliveryService.TrackCourier() to update the courier and active delivery tracks
together; calling Courier.UpdateLocation() alone changes only the courier.
Source: delivery.model.go.
A Delivery is one assigned piece of delivery work. It connects an order store to
a courier through IDs and owns execution status, stop snapshots, and tracking.
Field in DeliveryProps |
Type | Meaning and rules |
|---|---|---|
CourierID |
ddd.ID |
Required reference to the assigned courier. |
OrderStoreID |
ddd.ID |
Required reference to the store being delivered. |
Pickup |
Stop |
Valid pickup address and location snapshot. |
DropOff |
Stop |
Valid destination address and location snapshot. |
Status |
Status |
assigned, picked_up, delivered, or cancelled. |
AssignedAt |
time.Time |
Required assignment timestamp. |
History |
[]StatusEntry |
Chronological delivery status entries. |
Track |
[]TrackingPoint |
Recorded positions associated with this active delivery. |
NewDelivery(CreateDeliveryProps) takes the two IDs, stops, and assignment time.
It generates an internal ID, sets assigned, records the initial history entry,
and starts with an empty track. ValidateDelivery() checks references are nonempty,
stops are valid, assignment time exists, and status is recognized. The application
service loads referenced aggregates; the constructor does not query repositories.
| Model | Field | Type | Meaning and validation |
|---|---|---|---|
StopProps |
Address |
string |
Required nonblank address. |
StopProps |
Location |
geo.Location |
Valid stop coordinates. |
TrackingPoint |
Location |
geo.Location |
Valid observed courier position. |
TrackingPoint |
RecordedAt |
time.Time |
Required observation timestamp. |
StatusEntry |
Status |
Status |
Delivery status recorded by the aggregate. |
StatusEntry |
OccurredAt |
time.Time |
Time of the transition. |
NewStop() and ValidateStop() validate an immutable address/location snapshot;
Stop.Props() returns its value. NewTrackingPoint() and TrackingPoint.Validate()
validate an observation. StatusEntry is a plain record created inside delivery
behavior; it has no separate identity or public constructor.
| Behavior | Rules and effect |
|---|---|
Active() |
True only for assigned and picked_up. |
Transition(next, at) |
Enforces the transition table below and appends status history. The timestamp must be nonzero and not precede the latest status or tracking point. |
RecordLocation(point) |
Requires an active delivery and valid point. Its time must not precede the latest status or tracking point. Appends to Track. |
| Current status | Next status | Meaning |
|---|---|---|
assigned |
picked_up |
Courier has collected the goods. |
assigned |
cancelled |
Work was cancelled before pickup. |
picked_up |
delivered |
Courier completed delivery. |
delivered, cancelled |
None | Terminal; no further tracking or transitions. |
The delivery aggregate controls its own lifecycle. DeliveryService coordinates
that lifecycle with the order store and courier, and records location updates for
all active work. See Application services for the inputs,
steps, writes, and failures of ActiveDeliveries, Transition, and TrackCourier.
Sources: policy.go, assignment.go.
Dispatch decides whether a courier can handle a store and records the assignment. Its domain models contain decision inputs and rules. Its application services load Order/Courier aggregates, obtain routes through a port, and coordinate writes.
Requirements describes the order-store constraints:
| Field | Type | Meaning |
|---|---|---|
City |
string |
Required matching city. |
RequiresPOS |
bool |
Whether POS is mandatory. |
RequiresCar |
bool |
Whether a car is mandatory. |
Candidate is dispatch's snapshot of courier data, without a reference to a
mutable courier object:
| Field | Type | Meaning |
|---|---|---|
CourierID |
ddd.ID |
Courier aggregate identity. |
City |
string |
Courier's city pool. |
Online |
bool |
Derived from courier status being online. |
HasPOSDevice |
bool |
POS capability. |
HasCar |
bool |
Derived from vehicle type being car. |
Location |
geo.Location |
Latest position. |
LocationUpdatedAt |
time.Time |
Time used to check position freshness. |
ActiveTrips |
int |
Current reservation count. |
ActiveTripLimit |
int |
Courier's configured capacity. |
These are decision inputs constructed by the service. They have no independent repositories or lifecycle; the policy validates eligibility when evaluating them.
| Field | Type | Meaning |
|---|---|---|
PickupKM |
float64 |
Distance from the current courier position to the new pickup. |
TotalRouteKM |
float64 |
Estimated remaining distance including existing work and the new delivery. |
AdditionalKM |
float64 |
Distance added by the new delivery. |
Validate() requires finite, nonnegative distances and
AdditionalKM <= TotalRouteKM.
The Router outbound port accepts a RouteRequest with Origin, Pickup, and
DropOff locations, plus an ordered RemainingStops slice. Its Estimate() method
returns a RouteEstimate. The demo adapter uses straight-line distances:
- Visit existing active deliveries in assignment-time order, breaking ties by ID.
- Include pickup and drop-off for an assigned delivery, or only drop-off if picked up.
- Append the new pickup and drop-off after that work.
- Sum the old route and the added distance. Calculate
PickupKMdirectly from the current position, independently of existing stops.
There is no road network, traffic calculation, ETA, or search for the best insertion point. A future road-routing implementation belongs behind the same port.
| Field | Type | Default | Meaning |
|---|---|---|---|
MaxPickupKM |
float64 |
5 |
Largest allowed pickup distance. |
MaxRouteKM |
float64 |
25 |
Largest allowed total remaining route. |
MaxAdditionalKM |
float64 |
15 |
Largest allowed added route distance. |
MaxLocationAge |
time.Duration |
5 * time.Minute |
Maximum age of a courier position. |
PickupWeight |
float64 |
0.5 |
Pickup distance's contribution to scoring. |
RouteWeight |
float64 |
0.3 |
Total route distance's contribution. |
LoadWeight |
float64 |
0.2 |
Used capacity's contribution. |
DefaultParameters() returns these values. Parameters.Validate() requires
positive finite distance limits, a positive location-age limit, and finite
nonnegative weights with a positive finite sum. Individual weights may be zero;
they do not have to sum to one.
| Behavior | Responsibility |
|---|---|
NewPolicy(parameters) |
Validates and stores policy parameters. |
Policy.Eligible(requirements, candidate, now) |
Checks city, online state, location, capabilities, and spare capacity. Does not evaluate a route. |
Policy.Evaluate(requirements, candidate, route, now) |
Checks eligibility, validates route and parameters, applies distance limits, and returns a score with an acceptance flag. |
Rank(candidates) |
Returns a sorted copy, highest score first. Equal scores use ascending courier ID. |
A candidate needs a nonempty ID and matching nonblank city, online status, a valid
location with a nonzero timestamp no later than now, and age within the limit.
Required POS/car capabilities must be present. Active trips must be nonnegative
and strictly below a positive capacity limit. Distance limits are inclusive.
Eligible candidates receive:
pickupPenalty = PickupWeight × PickupKM / MaxPickupKM
routePenalty = RouteWeight × TotalRouteKM / MaxRouteKM
loadPenalty = LoadWeight × ActiveTrips / ActiveTripLimit
score = 100 × (1 - (pickupPenalty + routePenalty + loadPenalty)
/ (PickupWeight + RouteWeight + LoadWeight))
Higher is better. MaxAdditionalKM is an eligibility constraint; added distance
has no separate scoring weight. ScoredCandidate contains CourierID (ddd.ID),
Score (float64), and Route (RouteEstimate).
An assignment is the saved decision that produced a delivery. It remains after that delivery completes or is cancelled.
Field in AssignmentProps |
Type | Meaning and rules |
|---|---|---|
CourierID |
ddd.ID |
Required selected courier reference. |
OrderStoreID |
ddd.ID |
Required assigned store reference. |
DeliveryID |
ddd.ID |
Required created delivery reference. |
Method |
AssignmentMethod |
automatic or manual. |
Score |
float64 |
Evaluation score; finite and between 0 and 100. |
Route |
RouteEstimate |
Valid route estimate used for the decision. |
Parameters |
Parameters |
Valid policy settings used for the decision. |
AssignedAt |
time.Time |
Required decision timestamp. |
NewAssignment() generates an ID and defaults an omitted method to automatic.
ValidateAssignment() validates the fields above. There are no public mutation
methods: the decision is read through Props() and copied through Clone().
The memory repository rejects a second assignment for the same delivery ID.
Service.Dispatch evaluates the city's courier pool and chooses the best eligible
courier. ManualAssignService.Assign evaluates only the courier selected by the
caller. Both use the policy and assignment model above, and both commit the store,
courier, delivery, and assignment changes together. The detailed service contracts
and workflows are in Application services.
A service carries out a complete action requested by a caller. For example, “assign this store” involves an order store, a courier, a delivery, and an assignment record. The service loads those records, asks the domain models to apply their rules, and saves the changes together.
| Layer | Example responsibility |
|---|---|
| Handler | Read order_store_id from JSON and translate the result into an HTTP response. |
| Application service | Find a courier, create the delivery, reserve capacity, and save the assignment in a UOW. |
| Domain model | Reject assigning an unready store or reserving work beyond a courier's limit. |
| Repository adapter | Read and save aggregate copies in memory. |
Services accept Go inputs and return Go results/errors. They do not parse JSON, read Gin query/path parameters, or choose HTTP status codes. A service can be called from an HTTP handler or directly from Go code.
| Implementation | Constructor | Public operations | Plain-language job |
|---|---|---|---|
Dispatch Service |
dispatch_service.New(deps) |
Dispatch |
Find the best eligible courier and assign one store. |
ManualAssignService |
NewManualAssignService(deps) |
Assign |
Assign one store to the particular courier the caller chose. |
DeliveryService |
NewDeliveryService(deps) |
ActiveDeliveries, Transition, TrackCourier |
Follow delivery progress, update locations, and release capacity when work ends. |
There is currently no separate Order or Courier application service. Draft order construction uses aggregate methods, as shown in the seed code. Dispatch and Delivery services coordinate the Order and Courier aggregates during delivery work.
Sources: dispatch dependencies and workflow, delivery dependencies, container.go.
| Dependency | Automatic/manual assignment uses it to | Delivery service uses it to |
|---|---|---|
UOW |
Keep validation, selection, reservation, and all assignment writes in one transaction. | Keep lifecycle and tracking changes consistent across aggregates. |
Orders |
Load the selected store and save its new status/history. | Update the store after pickup, completion, or cancellation. |
Couriers |
Load candidates or the selected courier and save its capacity reservation. | Update location or release a delivery reservation. |
Deliveries |
Check active work, build remaining routes, and save the new delivery. | Query active deliveries, update progress, and append tracking points. |
Assignments |
Save the decision and the policy that produced it. | Not required; historical decisions remain unchanged. |
Router |
Estimate pickup, total remaining, and additional distances. | Not required; tracking does not recalculate a route. |
Clock |
Supply one Now() value for an assignment attempt. |
Not required; the caller supplies the timestamp to write operations. |
The container builds both assignment services from the same dispatch dependencies
and builds the delivery service from the same memory store. The default Router
is StraightLineRouter, and the default Clock returns current UTC time.
Constructors retain their supplied dependencies; they do not validate missing ports. Provide a complete dependency set. The repositories and UOW must refer to the same backing store so writes use the transaction's state.
Business request: “Find the best courier for this store in this city.”
Implementation: dispatch.service.go.
Contract: dispatch_ports.Service in service.go.
Dispatch(ctx context.Context, req DispatchRequest) (DispatchResult, error)| Input | Type | Required behavior |
|---|---|---|
ctx |
context.Context |
Passed to the UOW, repositories, and router. |
req.OrderStoreID |
ddd.ID |
Internal store aggregate ID; must be nonempty and identify an existing store. |
req.City |
string |
Nonblank selected city; must equal the store's city. |
req.Parameters |
dispatch_domain.Parameters |
Complete valid policy settings. Direct Go callers should use DefaultParameters() when they want defaults. |
The HTTP handler supplies defaults when its parameters field is omitted. The
automatic service itself does not replace a zero-valued Parameters struct with
defaults.
The operation proceeds as follows:
- Validate the request's store ID, city, and policy parameters.
- Enter the UOW and load the store. Require
order_is_readyand no active delivery. - Check the selected city matches the store and load that city's courier pool.
- Capture the clock time and evaluate each courier against requirements derived from the store: city, POS, and car needs.
- For eligible couriers, load active deliveries and verify that they agree with
ActiveDeliveryIDs. Construct their remaining stops and request a route estimate. - Reject candidates outside the route limits and score those that pass.
- Rank the accepted candidates and choose the highest score. Ties use courier ID.
- Run the shared assignment operation and commit its changes.
An ordinary eligibility rejection skips that courier. A repository failure, reservation mismatch, router error, or invalid route estimate aborts the whole attempt. Those errors are not treated as low scores or silently skipped.
The service returns ErrNoEligibleCourier only when no candidate is accepted.
There is no background retry, automatic queue, or partial assignment.
Business request: “Give this store's delivery to Sara.”
Implementation: manual-assign.service.go.
Contract: dispatch_ports.ManualAssignmentService.
Assign(ctx context.Context, req ManualAssignRequest) (DispatchResult, error)| Input | Type | Required behavior |
|---|---|---|
ctx |
context.Context |
Passed through the UOW and outbound ports. |
req.OrderStoreID |
ddd.ID |
Required nonblank internal store ID. |
req.CourierID |
ddd.ID |
Required nonblank internal courier ID chosen by the caller. |
req.Parameters |
*dispatch_domain.Parameters |
Optional. nil uses DefaultParameters(); a provided value must be a complete valid policy. |
There is no city input: the store determines the city the selected courier must belong to. The service:
- Validates both IDs and resolves the policy parameters.
- Enters the UOW and loads a ready, unassigned store.
- Loads the exact requested courier by ID; it does not search the courier pool.
- Applies the same eligibility, reservation-consistency, and routing checks as automatic assignment.
- Rejects an ineligible courier with
ErrCourierNotEligible. - Creates and commits the assignment with
Method = manual.
Manual assignment can choose a lower-scoring eligible courier. The returned score still describes that choice, but no comparison with other couriers is made. It never substitutes another courier. It also does not bypass online status, capacity, POS, vehicle, location freshness, or distance constraints.
Assigning a store that already has active work fails. This method does not transfer an existing delivery from one courier to another.
The manual service wraps an internal dispatch Service and reuses three private
helpers. This keeps both assignment methods subject to the same rules:
| Helper | Responsibility |
|---|---|
assignableStore |
Load the store, require readiness, and reject an active delivery. |
evaluateCourier |
Map store/courier details into policy inputs, verify reservations, estimate the route, and evaluate eligibility/score. |
assign |
Build stop snapshots and new records, reserve capacity, update the store, and save all four aggregates. |
The helpers use the caller's transaction context. They do not start nested UOWs.
A successful assignment changes these records together:
| Record | Before | After |
|---|---|---|
OrderStore |
Ready for assignment. | assigned, with a new status-history entry. |
Courier |
Has a spare capacity slot. | New delivery ID appended to ActiveDeliveryIDs. |
Delivery |
Does not exist. | Created with courier/store references, stop snapshots, assigned status, and initial history. |
Assignment |
Does not exist. | Created with all references, method, score, route, policy, and assignment time. |
The shared implementation saves the courier, store, delivery, and assignment in that order. Even if the final save fails, the UOW discards all earlier writes. The success result is returned only after the UOW completes successfully.
Field in DispatchResult |
Type | Meaning |
|---|---|---|
AssignmentID |
ddd.ID |
The saved decision's ID. |
DeliveryID |
ddd.ID |
The created delivery's ID; use it for delivery progress updates. |
CourierID |
ddd.ID |
Courier selected automatically or explicitly. |
OrderStoreID |
ddd.ID |
Assigned store's internal ID. |
Method |
AssignmentMethod |
automatic or manual. |
Score |
float64 |
Evaluation score for that courier and route. |
Route |
RouteEstimate |
Distances used for the assignment decision. |
Any failure returns an empty DispatchResult and an error. No successful result
is returned for changes that failed to commit.
Business request: “Which delivery jobs is this courier still handling?”
Implementation: delivery.service.go.
Contract: delivery_ports.DeliveryServiceInboundPort in
service.go.
ActiveDeliveries(ctx context.Context, courierID ddd.ID) ([]*Delivery, error)This is a read-only operation. It delegates to Deliveries.ActiveByCourier() and
does not start a UOW or change capacity. The returned deliveries have status
assigned or picked_up; completed and cancelled work is excluded.
The memory adapter returns copies ordered by assignment time and then ID. An unknown courier ID returns an empty slice: the service does not separately check that the courier exists. This result lists active work, not the courier's full historical delivery record.
Business request: “This delivery was picked up, delivered, or cancelled.”
Transition(ctx context.Context, deliveryID ddd.ID, next Status, at time.Time) error| Input | Meaning |
|---|---|
deliveryID |
Internal delivery ID, not an order-store or courier ID. |
next |
Requested next delivery status. Domain rules determine whether it is allowed. |
at |
Time of the change. Go callers supply it; the HTTP handler uses current UTC time. |
The service enters a UOW, loads the delivery, and asks it to perform the transition. It then loads the referenced order store and performs its matching transition:
| Delivery change | Order-store behavior | Courier behavior |
|---|---|---|
assigned → picked_up |
PickUp(at) adds the store's pickup status. |
Reservation remains active. |
picked_up → delivered |
Complete(at) adds the store's delivered status. |
ReleaseDelivery(deliveryID) removes the reservation. |
assigned → cancelled |
Cancel(at) adds the store's cancelled status. |
ReleaseDelivery(deliveryID) removes the reservation. |
Pickup saves the delivery and order store. Completion/cancellation also loads and saves the courier. Assignment records are left intact as the history of the original decision.
If the delivery transition succeeds in memory but the store transition fails, nothing is committed. The same applies if the courier is missing, the reservation cannot be released, or a repository save fails. Repeating a terminal transition returns an error, so capacity cannot be released twice.
Timestamps must be nonzero and chronological according to the affected aggregates. There is no separate check that a caller-supplied transition time is before the current wall-clock time.
Business request: “The courier has reported a new position.”
TrackCourier(ctx context.Context, courierID ddd.ID, location geo.Location, at time.Time) error| Input | Meaning |
|---|---|
courierID |
Internal ID of the courier reporting the position. |
location |
Valid geographic value created through NewLocation(). |
at |
Nonzero observation timestamp, supplied by the caller. |
The service validates a tracking point, then performs these steps inside a UOW:
- Load the courier and update its latest location and timestamp.
- Query all active deliveries assigned to that courier.
- Append the same position observation to each delivery's
Trackand save it. - Save the courier and commit the operation.
For a courier with two active deliveries, one report updates the courier and adds one tracking point to each of those two deliveries. Completed/cancelled deliveries are not touched. If there are no active deliveries, the operation only updates the courier's latest location.
A position may be new enough for the courier but older than a delivery's latest status or tracking point. In that case, the operation fails and rolls back the courier update and every delivery update already attempted.
Tracking does not assign orders, change delivery status, release capacity, or recalculate dispatch scores/routes. There is currently no HTTP endpoint for this service method. The method permits future observation times if chronological; dispatch excludes future-dated locations when deciding eligibility.
| Situation | Observable service behavior |
|---|---|
| Missing required request fields or invalid parameters | Assignment fails before any writes. |
| Missing store, courier, or delivery during a write operation | Repository error is returned; no changes commit. The memory adapter uses ErrNotFound. |
| Store not ready, already assigned, or in another selected city | Assignment fails without creating work. |
| No eligible automatic candidate | Returns ErrNoEligibleCourier. |
| Requested manual courier is ineligible | Returns ErrCourierNotEligible; no alternative is selected. |
| Courier reservations disagree with active deliveries | Assignment aborts rather than making a decision from inconsistent data. |
| Router failure or invalid route estimate | Assignment aborts. Router errors are wrapped with estimate route. |
| Illegal transition, backdated tracking, or missing reservation on release | Delivery operation fails; related changes roll back. |
| Repository save failure or cancelled context during a write operation | The UOW rejects the operation and discards changes. |
Services do not retry automatically or provide request-idempotency keys. A second assignment request after success fails because the store is already assigned; a second completion request fails because the delivery is terminal. Concurrent manual and automatic requests for the same store compete inside the same UOW, so at most one can commit in the current memory implementation.
All write service methods own their UOW. Call them directly with the ordinary
request context; do not call them from inside another UOW.Do() callback because
the current adapter rejects nested transactions. HTTP handlers translate service
errors using the mappings in Error responses.
Suppose a ready bakery order is assigned to Sara, who initially has no active work:
| Service call | Store status | Delivery status | Sara's active delivery IDs |
|---|---|---|---|
| Before assignment | order_is_ready |
No delivery yet. | [] |
ManualAssignService.Assign(...) |
assigned |
assigned |
[newDeliveryID] |
DeliveryService.TrackCourier(...) |
assigned |
assigned, with a new tracking point. |
[newDeliveryID] |
DeliveryService.Transition(..., PickedUp, at) |
picked_up |
picked_up |
[newDeliveryID] |
DeliveryService.Transition(..., Delivered, at) |
delivered |
delivered |
[] |
DeliveryService.ActiveDeliveries(..., saraID) |
Unchanged. | Completed delivery is excluded from the result. | [] |
The order and delivery histories remain available after completion, as does the assignment decision. Only the active reservation is removed from the courier.
Sources: store.go, seed.go, UOW port.
The infrastructure memory.Store is a container for repository state, distinct
from the business OrderStore. It holds four maps keyed by aggregate ID:
orders, couriers, deliveries, and assignments.
| Repository port | Operations |
|---|---|
| Order | Get, Save, List. |
| Courier | Get, Save, ListByCity. |
| Delivery | Get, Save, ActiveByCourier, ActiveByStore. |
| Dispatch | Get, Save, List for assignment records. |
Reads return copies. Saves require the transaction context from
UnitOfWork.Do(ctx, callback). The memory adapter serializes callbacks, clones
state, and publishes changes only after a successful callback with a noncancelled
context. Errors and panics discard changes; a panic still propagates to the caller.
Use the supplied transaction context for every repository call inside the callback. Read aggregates inside the UOW before modifying them. Nested UOW calls, contexts retained after completion, and writes outside a UOW are rejected. Do not share the transaction context across goroutines.
These rules protect automatic and manual dispatch from duplicate assignments and capacity races in this process. A persistent adapter would need equivalent atomicity and concurrency control. The current UOW does not provide durability across restarts.
SeedDemo() constructs data through domain constructors and aggregate methods.
The container runs it once at startup.
| Courier | Status | Vehicle | POS | Active-trip limit |
|---|---|---|---|---|
| Ali | Online | Bike | No | 2 |
| Sara | Online | Car | Yes | 2 |
| Reza | Offline | Car | Yes | 3 (default) |
All three belong to tehran and receive initial positions. Two ready stores share
external OrderId = "demo-order-1":
| External OrderStoreId | VendorId | Vendor | Delivery requirement |
|---|---|---|---|
1001 |
101 |
Bakery | Either vehicle; POS not required. |
1002 |
102 |
Market | Car and POS required. |
Each contains a customer snapshot and an item with Count = 2, Unit = "piece".
Deliveries and assignments begin empty and are created by dispatch operations.
The fixture code contains the sample addresses, coordinates, and contact details.
Courier positions age out after five minutes under the default policy. Refresh
positions through DeliveryService.TrackCourier() or restart to recreate fixtures.
Restarting also discards every assignment and generates new internal IDs.
Routes are registered in domain_routes.go and server.go. Body parsing, parameter reading, and response handling belong to handlers.
| Method and path | Input | Successful response |
|---|---|---|
GET /health |
None. | 200 with {"status":"ok"}. |
POST /dispatch |
order_store_id, city, optional parameters. |
201 with DispatchResult. |
POST /dispatch/manual |
order_store_id, courier_id, optional parameters. |
201 with DispatchResult. |
GET /couriers/:id/deliveries |
Courier aggregate ID in the path. | 200 with active delivery summaries, or []. |
POST /deliveries/:id/status |
Delivery aggregate ID and {"status":"..."}. |
204 with an empty body. |
The current API has no store/courier listing or creation route. /demo/stores is
not registered. Obtain internal IDs through the in-memory repository ports in Go;
external fixture IDs such as 1001 cannot be used as aggregate IDs. Tests in
container demonstrate retrieving seeded IDs
and exercising the HTTP handlers against the same running container.
Use the port from your configuration. These examples assume 8000. Set the shell
variables to actual internal IDs from the running process before making requests.
Choose automatic or manual assignment for a store; a second assignment will fail.
# Automatic selection
curl -X POST http://localhost:8000/dispatch \
-H 'Content-Type: application/json' \
-d "{\"order_store_id\":\"$STORE_ID\",\"city\":\"tehran\"}"
# Explicit courier choice
curl -X POST http://localhost:8000/dispatch/manual \
-H 'Content-Type: application/json' \
-d "{\"order_store_id\":\"$STORE_ID\",\"courier_id\":\"$COURIER_ID\"}"Example response shape (IDs and score are illustrative):
{
"AssignmentID": "<assignment-id>",
"DeliveryID": "<delivery-id>",
"CourierID": "<courier-id>",
"OrderStoreID": "<store-id>",
"Method": "manual",
"Score": 96.0,
"Route": {
"PickupKM": 0.2,
"TotalRouteKM": 1.6666666667,
"AdditionalKM": 1.6666666667
}
}The request uses snake_case keys; the current dispatch result uses Go field names
in JSON. For automatic assignment, Method is automatic.
When parameters is supplied, it replaces the complete default parameter set.
Use the exported field names shown below. Durations are JSON numbers in
nanoseconds, so five minutes is 300000000000:
{
"order_store_id": "<store-id>",
"courier_id": "<courier-id>",
"parameters": {
"MaxPickupKM": 5,
"MaxRouteKM": 25,
"MaxAdditionalKM": 15,
"MaxLocationAge": 300000000000,
"PickupWeight": 0.5,
"RouteWeight": 0.3,
"LoadWeight": 0.2
}
}curl "http://localhost:8000/couriers/$COURIER_ID/deliveries"
curl -X POST "http://localhost:8000/deliveries/$DELIVERY_ID/status" \
-H 'Content-Type: application/json' -d '{"status":"picked_up"}'
curl -X POST "http://localhost:8000/deliveries/$DELIVERY_ID/status" \
-H 'Content-Type: application/json' -d '{"status":"delivered"}'The active-deliveries response contains id, courier_id, order_store_id,
status, and assigned_at for each delivery. It excludes completed and cancelled
work. An unknown courier currently returns [] rather than 404.
Status requests use server UTC time. cancelled is accepted only before pickup.
Location tracking exists as an application service; there is no HTTP location-update
endpoint yet.
Errors have the shape {"error":"message"}. Current mappings are:
| Condition | Status |
|---|---|
| Malformed or incompatible JSON body | 400 |
| Missing required IDs in the manual-assignment body | 400 |
| Dispatch failure, including invalid automatic inputs, missing aggregates, ineligible courier, or duplicate assignment | 409 |
| Invalid delivery transition or missing delivery | 409 |
| Error reading active deliveries | 400 |
These are the current handler mappings; the API does not yet distinguish every business failure into separate HTTP status codes.
go test ./...
go test -race ./...
go vet ./...If the environment restricts Go's default build cache, prefix the commands with
GOCACHE=/tmp/courierflow-go-cache.
The suite covers constructor validation, order edits and history, courier capacity, delivery lifecycle and tracking, eligibility and scoring, failed-write rollback, context cancellation/panic rollback, duplicate assignment races, capacity races, manual courier choice, configuration precedence, and HTTP dependency wiring.
Current scope is the domain model and an executable in-memory application:
- Persistence is process-local, and fixtures reset on restart.
- Routing uses straight-line distance, with no traffic or ETA optimization.
Totalhas no currency/price calculation; items have no pricing or load dimensions.- Order readiness is explicit; preparation time does not trigger background work.
- There is no reassignment, retry/reopen lifecycle, or cancellation after pickup.
- There is no authentication, user/actor audit field, or complete order/courier admin API.
- Status history and tracking are stored records; no domain-event publisher is implemented.