Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5807214
feat(di)!: a provider declares its dependencies by name
btravers Aug 20, 2026
7f49fa5
refactor(di): declare a provider's dependencies by name
btravers Aug 20, 2026
db3f0ed
refactor(config): name the Env dependency Config.provider binds
btravers Aug 20, 2026
53f09ee
refactor(core): name the dependencies the kernel's fixtures and type …
btravers Aug 20, 2026
ace24bd
refactor(example): name the dependencies the container example declares
btravers Aug 20, 2026
19f50c2
refactor(observability): name the LoggerConfig dependency; key tapped…
btravers Aug 20, 2026
4d419c3
fix(di): an empty deps record still hands the factory a record
btravers Aug 20, 2026
4ff9cdb
refactor(http): take a deps record in the three helpers that declare …
btravers Aug 20, 2026
32f77cb
refactor(temporal): take a deps record in the activities builders
btravers Aug 20, 2026
515d136
refactor(amqp): take a deps record in the handlers builders
btravers Aug 20, 2026
4cef35e
refactor(example): name the dependencies the application and persiste…
btravers Aug 20, 2026
08366ba
refactor(example): name the dependencies the order API's slices declare
btravers Aug 20, 2026
9f84407
refactor(example): name the dependencies the AMQP worker declares
btravers Aug 20, 2026
f7278cc
refactor(example): name the dependencies the Temporal worker declares
btravers Aug 20, 2026
63a4195
docs: the deps record in every README and package spec
btravers Aug 20, 2026
139e92b
docs: the deps record across the documentation site
btravers Aug 20, 2026
07e9cd1
refactor(http): give the three helpers a no-deps arm, as di's provide…
btravers Aug 20, 2026
fac8df7
test(http): the contract key that could confuse the router's discrimi…
btravers Aug 20, 2026
85e7444
fix(http): an arm-only router's sync is handed nothing, and the arms …
btravers Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,9 +598,9 @@ label=com.btravstack.test-infra)` clears them), and testcontainers' own reuse
namespace }` back off `Serving.info`. The Worker's lifecycle, the unit per
attempt and the deadline race are the package's. It is a **two-slice
modulith**: `FulfillmentSlice`'s `fulfillOrder = TemporalWorkflowActivities(orderContract,
"fulfillOrder")([PlaceOrder, OrderRepository, StockService, ShippingService],
{ sync })` and `BillingSlice`'s `chargeOrder = TemporalWorkflowActivities(orderContract,
"chargeOrder")([PaymentService], { sync })` are each a **piece** — a provider
"fulfillOrder")({ place: PlaceOrder, repository: OrderRepository, stock: StockService,
shipping: ShippingService }, { sync })` and `BillingSlice`'s `chargeOrder = TemporalWorkflowActivities(orderContract,
"chargeOrder")({ payments: PaymentService }, { sync })` are each a **piece** — a provider
on the port its own contract key mints, closing over only the services its
own saga calls, no context read at call time — and the root composes them,
`orderActivities = TemporalActivities(orderContract)([fulfillOrder,
Expand All @@ -615,8 +615,8 @@ observability()] })`, the sugar importing the starter. `FulfillmentSlice`
from the starter, and `LOG_LEVEL` and the `Logger` the sagas' stand-in
services write to come from `observability()`. `order-amqp-worker` is the
same shape — `NotificationsSlice`'s `orderNotifications = AmqpHandler(orderContract,
"orderNotifications")([Logger], { sync })` and `AuditSlice`'s `orderAudit =
AmqpHandler(orderContract, "orderAudit")([Logger], { sync })`, composed as
"orderNotifications")({ logger: Logger }, { sync })` and `AuditSlice`'s `orderAudit =
AmqpHandler(orderContract, "orderAudit")({ logger: Logger }, { sync })`, composed as
`orderHandlers = AmqpHandlers(orderContract)([orderNotifications,
orderAudit])` — but **neither** slice imports a vertical: a subscriber reacts
to a fact somebody else already committed, so the orders vertical stays at
Expand Down Expand Up @@ -646,13 +646,13 @@ AuditSlice, observability()], … })`),
(if it needs one) its own adapter, and ships as an ordinary di `Module` that
exports only that piece's port — everything else about the slice stays
private. `@btravstack/http`'s
`HttpController(name, fragment)([deps], { sync })` mints the controller's
`HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's
port; the root composes every slice's controller into one router with the
keyed `HttpRouter(contract)(controllers)` form, exact against the contract
(see `packages/http/CLAUDE.md`). **A fragment is itself a valid contract**,
so a slice lifts out of the modulith into a process of its own without its
controller changing at all: the lifted root is
`HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })`,
`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`,
declaring the very provider the modulith composed and handing back what it
built — a new composition root and one fewer import,
not a rewrite of the slice. That exact call is `controller.test-d.ts`'s fifth
Expand Down
53 changes: 28 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,31 +99,34 @@ export const ordersContract = {
import { HttpRouter } from "@btravstack/http";
import { P } from "unthrown";

export const ordersRouter = HttpRouter(ordersContract)([PlaceOrder], {
sync: (place) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
.map((order) => ({ id: order.id, quantity: order.quantity }))
// The one place a domain error becomes a transport one — exhaustive,
// so a new domain error is a compile error here.
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
data: { id: error.id },
}),
),
),
}),
});
export const ordersRouter = HttpRouter(ordersContract)(
{ place: PlaceOrder },
{
sync: ({ place }) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
.map((order) => ({ id: order.id, quantity: order.quantity }))
// The one place a domain error becomes a transport one — exhaustive,
// so a new domain error is a compile error here.
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
data: { id: error.id },
}),
),
),
}),
},
);
```

```ts
Expand Down
39 changes: 24 additions & 15 deletions docs/examples/hexagonal-order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,26 @@ export const makePersistenceModule = () =>
Module("Persistence")({
imports: [ConfigModule],
provides: [
Provider(Pool)([AppConfig], {
acquire: openPool,
release: (pool) => pool.close(),
}),
Provider(OrderRepository)([Pool], {
sync: (pool) => ({
findById: (id) => {
const row = pool.findById(id);
return (
row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)
).toAsync();
},
}),
}),
Provider(Pool)(
{ config: AppConfig },
{
acquire: openPool,
release: (pool) => pool.close(),
},
),
Provider(OrderRepository)(
{ pool: Pool },
{
sync: ({ pool }) => ({
findById: (id) => {
const row = pool.findById(id);
return (
row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)
).toAsync();
},
}),
},
),
],
exports: [OrderRepository],
});
Expand Down Expand Up @@ -103,7 +109,10 @@ export const makeAppModule = <E, N>(
Module("App")({
imports: [persistence],
provides: [
Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }),
Provider(GetOrder)(
{ orders: OrderRepository },
{ class: GetOrderInteractor },
),
],
exports: [GetOrder],
});
Expand Down
43 changes: 25 additions & 18 deletions docs/examples/order-amqp-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,26 +57,33 @@ name, since the contract key IS the port's name:
export const orderNotifications = AmqpHandler(
orderContract,
"orderNotifications",
)([Logger], {
sync: (logger) => (message) => {
const { id, payload } = message.payload;
if (currentUnit()?.signal.aborted === true) {
return ErrAsync(
new RetryableError(
`the drain deadline passed before order ${id} was notified`,
),
);
}
logger.info(
payload === null ? "order gone — notifying" : "order placed — notifying",
{
orderId: id,
...(payload === null ? {} : { quantity: payload.quantity }),
)(
{ logger: Logger },
{
sync:
({ logger }) =>
(message) => {
const { id, payload } = message.payload;
if (currentUnit()?.signal.aborted === true) {
return ErrAsync(
new RetryableError(
`the drain deadline passed before order ${id} was notified`,
),
);
}
logger.info(
payload === null
? "order gone — notifying"
: "order placed — notifying",
{
orderId: id,
...(payload === null ? {} : { quantity: payload.quantity }),
},
);
return OkAsync();
},
);
return OkAsync();
},
});
);
```

The audit slice is the same shape over `"orderAudit"`, minus the deadline
Expand Down
106 changes: 56 additions & 50 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ import { ErrAsync, OkAsync } from "unthrown";

import { HttpAuthenticator } from "./auth.js";

export const bearerAuthenticator = HttpAuthenticator([], {
export const bearerAuthenticator = HttpAuthenticator({
sync: () => (headers) => {
const header = headers.authorization ?? "";
const token = header.startsWith("Bearer ")
Expand Down Expand Up @@ -189,9 +189,9 @@ use cases in [`order-application`](/examples/order-application), and the
entities and Prisma adapters behind it.

```
src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder, Logger], { sync })
src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync })
src/slices/orders/module.ts OrdersSlice — imports the vertical, provides the controller, exports only it
src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)([FindCustomer], { sync })
src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync })
src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice
```

Expand All @@ -205,45 +205,48 @@ import { HttpController } from "../../auth.js";
export const ordersController = HttpController(
"OrdersController",
contract.orders,
)([PlaceOrder, FindOrder, Logger], {
sync: (place, find, logger) => ({
place: ({ errors, context }, input) => {
logger.info("order placement requested", {
userId: context.principal.userId,
});
return place
.execute(context.principal.tenantId, input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
)(
{ place: PlaceOrder, find: FindOrder, logger: Logger },
{
sync: ({ place, find, logger }) => ({
place: ({ errors, context }, input) => {
logger.info("order placement requested", {
userId: context.principal.userId,
});
return place
.execute(context.principal.tenantId, input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
data: { id: error.id },
}),
),
);
},
find: ({ errors, context }, input) =>
find
.execute(context.principal.tenantId, input.id)
.map(view)
.mapErrCases((matcher) =>
matcher.with(P.tag("OrderNotFound"), (error) =>
errors.NOT_FOUND({
message: error.message,
data: { id: error.id },
}),
),
);
},
find: ({ errors, context }, input) =>
find
.execute(context.principal.tenantId, input.id)
.map(view)
.mapErrCases((matcher) =>
matcher.with(P.tag("OrderNotFound"), (error) =>
errors.NOT_FOUND({
message: error.message,
data: { id: error.id },
}),
),
),
}),
});
}),
},
);
```

Each leaf is the `.result()` handler `@unthrown/orpc` gives that procedure's
Expand Down Expand Up @@ -310,7 +313,7 @@ the recipe, and `packages/http/src/controller.test-d.ts` for the five gates
that pin these errors and the lift below. Because a fragment is itself a valid
contract, `ordersController` serves `contract.orders` alone unchanged: the
lifted root is
`HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })`
`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`
over `OrdersSlice`, so extracting a slice out of this modulith is a new
composition root and one fewer import, not a rewrite.

Expand Down Expand Up @@ -407,18 +410,21 @@ export class RequestSpan extends Port("RequestSpan")<{

export const RequestModule = Module("Request")({
provides: [
Provider(RequestSpan)([Logger], {
sync: (logger) => {
const startedAt = Date.now();
return {
finish: () =>
logger.info("request finished", {
durationMs: Date.now() - startedAt,
}),
};
Provider(RequestSpan)(
{ logger: Logger },
{
sync: ({ logger }) => {
const startedAt = Date.now();
return {
finish: () =>
logger.info("request finished", {
durationMs: Date.now() - startedAt,
}),
};
},
onStop: (span) => span.finish(),
},
onStop: (span) => span.finish(),
}),
),
],
exports: [RequestSpan],
});
Expand Down Expand Up @@ -556,7 +562,7 @@ authenticator discharges the need. `HttpModule` compares the router's identity
against the authenticator's itself, at the option:

```ts
const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], {
const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({
sync: () => () => OkAsync({ sub: "s-1" }),
});

Expand Down
31 changes: 17 additions & 14 deletions docs/examples/order-temporal-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,24 @@ declare is a compile error in that slice's own file, not a defect
export const chargeOrder = TemporalWorkflowActivities(
orderContract,
"chargeOrder",
)([PaymentService], {
sync: (payments) => ({
authorizePayment: (args, { errors }) =>
payments
.authorize(args.orderId, args.amount)
.map((authorizationId) => ({ authorizationId }))
.mapErrCases((matcher) =>
matcher.with(P.tag("PaymentDeclined"), (error) =>
errors.PaymentDeclined({ id: error.id }),
)(
{ payments: PaymentService },
{
sync: ({ payments }) => ({
authorizePayment: (args, { errors }) =>
payments
.authorize(args.orderId, args.amount)
.map((authorizationId) => ({ authorizationId }))
.mapErrCases((matcher) =>
matcher.with(P.tag("PaymentDeclined"), (error) =>
errors.PaymentDeclined({ id: error.id }),
),
),
),
capturePayment: (args) => payments.capture(args.authorizationId),
refundPayment: (args) => payments.refund(args.authorizationId),
}),
});
capturePayment: (args) => payments.capture(args.authorizationId),
refundPayment: (args) => payments.refund(args.authorizationId),
}),
},
);
```

`fulfillOrder`'s own piece is the same activities record this example always
Expand Down
Loading
Loading