Build a delivery service
This tutorial connects the MAMS architecture to a complete runnable application. A client submits a delivery request through HTTP. An ASTRA coordinator chooses a courier, publishes progress and records either successful delivery or failure. A courier can recover from a blocked route by trying an alternative.
The example uses MAMS 2.4.1, ASTRA 2.6.1 and Java 17 or later. Complete Your first MAMS service first. The resource API reference explains the goals, beliefs, events and operations used below.
Download the delivery service project. Extract the archive and run this command from the project directory:
mvn clean compile astra:run
Stop any other example using port 9000 first. Leave this service running while you submit requests from a second terminal.
Define the service boundary
The service contains a coordinator, main, and two internal agents, courier-1 and courier-2. The coordinator exposes /main/deliveries as a collection of requests. The courier agents use local ASTRA messages and have no HTTP resource bodies of their own.
This follows the microservice application diagram: the agents together implement one delivery capability. An ordinary order service could submit the same HTTP requests shown here. The tutorial does not require an external order or stock service.
Courier travel is simulated with route beliefs. There is no real vehicle, clock or geographic routing engine. This makes the successful and failed paths repeatable while retaining the resource and coordination lifecycle.
Define the request and result
public class Delivery {
public String orderId;
public String destination;
public String status;
public String courier;
public String detail;
public String history;
}
Clients submit orderId and destination. The coordinator owns status, courier, detail and history:
Field |
Meaning |
|---|---|
|
The client-provided order identifier |
|
The requested destination |
|
Current processing stage, ending in |
|
Name of the selected internal courier agent |
|
Explanation of the current stage or outcome |
|
Ordered record of the stages published by the coordinator |
The history string preserves intermediate stages because this small simulation may finish before a client reads the first response. It is a teaching aid; a production contract could use structured events and timestamps.
Protect agent-owned state
The coordinator creates an active collection with !listResource("deliveries", "Delivery"). Its request policy permits GET and OPTIONS, and POST to the collection. It refuses client PUT and DELETE requests with 403 Forbidden.
Clients can create requests and read results, but cannot overwrite the coordinator’s published progress. Supported HTTP requests pass through the active decision plan; methods unsupported by the underlying artifact return 405 Method Not Allowed before that plan.
On creation, the coordinator resets the service-owned fields rather than trusting values supplied by the client. The contract requires non-empty orderId and destination. Semantic validation happens after creation, so a missing required value produces a resource with status: "failed", rather than rejecting its creation. Malformed JSON is handled by the resource implementation.
Submit a successful delivery
curl -i -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Slug: order-1' \
-d '{"orderId":"order-1","destination":"library"}' \
http://localhost:9000/main/deliveries
Expect 201 Created. Read the URI in the Location header:
curl -i -H 'Accept: application/json' \
http://localhost:9000/main/deliveries/order-1
A completed result has these values:
{
"orderId": "order-1",
"destination": "library",
"status": "delivered",
"courier": "courier-1",
"detail": "Arrived via park",
"history": "submitted -> assigned -> in_transit -> retrying -> delivered"
}
The coordinator chooses courier-1 for the library and courier-2 for other destinations. This is a simple assignment policy that can later be replaced with capacity, location or cost reasoning.
The courier first tries the bridge route, which is marked blocked. Its try/recover logic tries the park route and succeeds. Each progress report is published before the coordinator acknowledges it. The courier waits for that acknowledgement before reporting the next stage.
201 Created acknowledges creation of the request, not delivery. Poll the returned URI until status becomes delivered or failed, using an appropriate delay and timeout in a real client. An early GET may show an intermediate stage. The service can also notify its agent of resource replacements independently of the HTTP response.
Exercise the failure path
Submit a destination with no configured route:
curl -i -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Slug: order-2' \
-d '{"orderId":"order-2","destination":"island"}' \
http://localhost:9000/main/deliveries
Read /main/deliveries/order-2. Its terminal result includes:
{
"orderId": "order-2",
"destination": "island",
"status": "failed",
"courier": "courier-2",
"detail": "No usable route",
"history": "submitted -> assigned -> in_transit -> retrying -> failed"
}
The failed alternative goal is recovered into an explicit failure report. The coordinator publishes it and removes the live assignment belief. It does not silently leave the request in transit.
For a second success path, submit destination: "depot" with a new order identifier and slug. courier-2 recovers through main-road. Submitting an empty orderId or destination publishes a failed validation result before assigning a courier.
Use a fresh slug for each creation. Reusing an existing child name returns 409 Conflict; the order identifier itself is not used to deduplicate requests.
Inspect the coordinator
agent Main extends mams.javatypes.Active {
module ObjectAccess oa;
types delivery {
formula deliver(string, string);
formula progress(string, string, string);
formula assigned(string, string);
formula acknowledged(string, string);
}
plan +!main(list args) {
mams.MAMSAgent::!setup();
S.createAgent("courier-1", "Courier");
S.createAgent("courier-2", "Courier");
mams.MAMSAgent::!created("base");
mams.javatypes.Active::!listResource("deliveries", "Delivery");
console.println("Delivery service ready at /main/deliveries");
}
// Clients may submit requests and read progress; the agent owns updates.
plan $cartago.signal(string Name, httpEvent(int Index, string Method))
: artifact(string LogicalName, Name, cartago.ArtifactId Id) {
if (Method == "GET" | Method == "OPTIONS") {
mams.javatypes.Active::!acceptRequest(Index, Name);
} else if (LogicalName == "deliveries" & Method == "POST") {
mams.javatypes.Active::!acceptRequest(Index, Name);
} else {
mams.javatypes.Active::!refuseRequest(Index, Name);
}
}
plan +itemResource(string Name, "Delivery")
: artifact(Name, Name, cartago.ArtifactId Id) {
cartago.operation(Id, get(Delivery Value));
string Order = oa.getString(Value, "orderId");
string Destination = oa.getString(Value, "destination");
// Reset service-owned fields, regardless of the submitted values.
oa.set(Value, "courier", "");
oa.set(Value, "history", "");
cartago.operation(Id, replace(Value));
!publish(Name, "submitted", "Request received");
if (Order == "" | Destination == "") {
!publish(Name, "failed", "orderId and destination are required");
} else {
string Worker = "courier-2";
if (Destination == "library") { Worker = "courier-1"; }
cartago.operation(Id, get(Delivery Current));
oa.set(Current, "courier", Worker);
cartago.operation(Id, replace(Current));
+assigned(Name, Worker);
!publish(Name, "assigned", "Assigned to " + Worker);
send(request, Worker, deliver(Name, Destination));
}
}
plan @message(inform, string Worker,
progress(string Name, string Status, string Detail))
: assigned(Name, Worker) {
!publish(Name, Status, Detail);
send(inform, Worker, acknowledged(Name, Status));
if (Status == "delivered" | Status == "failed") {
-assigned(Name, Worker);
}
}
synchronized<resource_updates> plan +!publish(string Name, string Status, string Detail)
: artifact(Name, Name, cartago.ArtifactId Id) {
cartago.operation(Id, get(Delivery Value));
string History = oa.getString(Value, "history");
if (History == "") { History = Status; }
else { History = History + " -> " + Status; }
oa.set(Value, "status", Status);
oa.set(Value, "detail", Detail);
oa.set(Value, "history", History);
cartago.operation(Id, replace(Value));
}
// Acceptance does not suppress the post-update notification.
plan $cartago.signal(string Name, replaced(Object Value)) {
console.println(Name + ": " + oa.getString(Value, "status"));
}
}
The important steps are:
Start shared MAMS infrastructure and create the internal workers before exposing the collection.
Accept a POST to create a child resource. The inherited active class discovers and focuses on the child, then adds its
itemResourcebelief.Handle
+itemResource(...), read the new request, reset service-owned fields and validate the inputs.Choose a worker, record
assigned(ResourceName, Worker)and send thedeliverrequest.Accept progress messages only from the worker associated with that resource, publish their state and acknowledge publication.
Remove the assignment when its state reaches
deliveredorfailed.
The publication plan uses a named synchronization lock so concurrent progress updates read and replace resource state consistently within the coordinator. The lock does not make external reads transactional. The active request policy prevents external writers from competing with these updates.
replace(Value) emits a replaced signal. The coordinator’s signal plan logs that notification: active resource management adds the opportunity to refuse HTTP requests, while retaining observation of resource changes.
Inspect a courier
agent Courier {
module System system;
types delivery {
formula deliver(string, string);
formula progress(string, string, string);
formula acknowledged(string, string);
formula route(string, string);
formula blocked(string);
}
initial route("library", "bridge"), route("library", "park"),
route("depot", "main-road"), blocked("bridge");
plan @message(request, "main", deliver(string Name, string Destination)) {
!report(Name, "in_transit", "Courier started");
try {
!travel(Destination, "bridge");
!report(Name, "delivered", "Arrived via bridge");
} recover {
!report(Name, "retrying", "Trying an alternative route");
try {
!alternative(Destination, string Road);
!travel(Destination, Road);
!report(Name, "delivered", "Arrived via " + Road);
} recover {
!report(Name, "failed", "No usable route");
}
}
}
plan @message(inform, "main", acknowledged(string Name, string Status)) {
+acknowledged(Name, Status);
}
plan +!report(string Name, string Status, string Detail) {
send(inform, "main", progress(Name, Status, Detail));
wait(acknowledged(Name, Status));
-acknowledged(Name, Status);
}
plan +!alternative("library", string Road) { Road = "park"; }
plan +!alternative("depot", string Road) { Road = "main-road"; }
plan +!alternative(string Destination, string Road) { fail; }
plan +!travel(string Destination, string Road) : blocked(Road) { fail; }
plan +!travel(string Destination, string Road) : route(Destination, Road) { }
plan +!travel(string Destination, string Road) { fail; }
}
A courier receives a request through an ASTRA message, reports progress and uses goals to try routes. Its route beliefs supply the simulated environment. The specific blocked-road plan precedes the usable-route plan, and explicit failing fallback plans make unsupported destinations reach the recovery path.
Progress uses the resource’s qualified name as a correlation identifier. Acknowledgements include both that name and the reported status, allowing work on different requests to remain distinct.
Confirm the request policy
Try to forge completion from the HTTP client:
curl -i -X PUT \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"status":"delivered"}' \
http://localhost:9000/main/deliveries/order-2
The response is 403 Forbidden; GET still shows the failed request. DELETE is also refused. The policy protects the example’s service-owned state but does not authenticate clients.
Extend the service
The example finishes business work or publishes an explicit failure, but retains its resources until the process stops. Restarting does not restore them. It also assumes internal workers remain available: a missing acknowledgement or stopped worker needs a timeout and recovery policy in a longer-lived application.
Useful extensions include persistent storage, structured progress events, idempotent order submission, authenticated requests, courier capacity and discovery of ordinary order or stock services. Add those contracts deliberately rather than inferring them from successful HTTP creation.
See Deploying a MAMS Service for Java and container launch instructions, and Verifying the MAMS Examples for automated checks.