Datrix is a domain-specific language for defining microservice architectures. A .dtrx file describes your entire backend — services, data models, APIs, messaging, caching, and infrastructure — in a concise, platform-agnostic syntax. The Datrix compiler validates your definitions and generates production-ready code for your target language and platform.
Every Datrix application is organized around a few top-level blocks. The entry point,
system.dtrx, starts with one include '<file>.dtrx'; line per other file (paths are relative
to the including file) and declares the system:
system ecommerce.Platform('config/system.dcfg') : version('1.0.0') {
use extension geo;
}
| Block | Cardinality | Purpose |
|---|---|---|
system |
Exactly one per application | Application identity, discovery, use extension; deployment, gateway and observability settings live in its .dcfg |
module |
Multiple | Shared code: traits, abstract entities, enums, structs, constants, exceptions, scalar |
service |
Multiple | Business logic + owned infrastructure (rdbms, pubsub, queues, APIs, enqueue, …) |
extern service |
Multiple | Contract for an external library/tool — generates typed clients, no implementation code |
Multiple files can contribute to the same service — the compiler merges them automatically.
Two comment forms, each with a plain and a doc-marked variant:
| Form | Plain (source-only) | Doc-marked (may publish) |
|---|---|---|
| Line | // text |
/// text |
| Block | /* text */ |
/** text */ |
Every comment survives into the generated code. A comment attaches to the construct
beside it and reaches the generated artifact as an ordinary comment in the target language's
own syntax — # in Python, // in TypeScript, -- in SQL.
Only a doc-marked comment is published. /// and /** */ additionally reach the
documentation your API consumers see: the OpenAPI summary/description, a schema field's
and component's description, GraphQL type/field descriptions, the generated README, and —
for an entity or one of its fields — the database comment (COMMENT ON TABLE /
COMMENT ON COLUMN). Publication is opt-in on purpose: a .dtrx comment is written for the
next engineer reading the DSL, not for whoever reads /docs, so nothing is exported unless
you marked it.
/// A purchasable product listed in the storefront catalog.
entity Product {
UUID id : primaryKey, server = uuid();
/// The product's shopper-facing display name.
String(200) title;
// Internal: legacy SKU from the old catalog, not for API consumers.
String(50) legacySku;
}
Product and title are documented publicly — their text reaches the OpenAPI document and
the database catalog. legacySku's note reaches the generated source only.
Attachment. A comment (or a contiguous run of comments with no blank line between them)
attaches to whichever construct it sits beside: a run immediately above a construct
documents it, and a run starting on the same line a construct ends on documents that
construct. A run separated from every construct by a blank line is detached — it attaches
to nothing and is not emitted, which is what makes section dividers (// ── Ingestion ──)
safe to write.
Mixed markers are an error. A single contiguous run must be entirely doc-marked or entirely plain; a half-marked block raises at parse time naming the file and line.
Doc-marked comments also carry structured tags (@param, @returns, @deprecated,
@example). Full detail, including tag syntax and the per-target landing sites:
datrix-syntax-reference.md.
Editing a doc-marked comment on an entity or field plans a migration revision — that text lives in the database catalog, so changing it is a schema change. Editing any other comment, in either channel, never does.
A service is the unit of deployment. Everything inside a service block belongs to that service.
service ecommerce.OrderService('config/order-service.dcfg') : version('1.0.0') {
discovery {
UserService { loadBalance: roundRobin, healthyOnly: true }
PaymentService { loadBalance: roundRobin, healthyOnly: true }
}
rdbms db {
entity Order {
UUID id : primaryKey, server = uuid();
Money total;
}
}
cache redis {
counter OrderViews : prefix('order:views');
}
pubsub mq {
topic OrderEvents {
publish OrderPlaced(UUID orderId);
}
}
queues {
queue ProcessPayment(UUID orderId, Money amount);
}
rest_api OrderAPI : basePath('/api/v1/orders'), rdbms(db) {
resource Order : auth(required, providers: [identity]);
}
}
Entities define your data model with fields, types, relationships, validation, and lifecycle hooks.
entity Order extends BaseEntity with Auditable {
belongsTo Customer : index;
String(200) description : trim;
Money total;
OrderStatus status = OrderStatus.Pending;
Boolean isOverdue := dueDate < DateTime.now();
index(customerId, status);
afterCreate {
dispatch OrderCreated(id, total);
}
}
Key features:
- Inheritance:
extends BaseEntityinherits fields from abstract entities - Traits:
with Auditablemixes in reusable field groups - Relationships:
belongsTo,hasMany,hasOne, andmanyToManydefine entity relationships.manyToManygenerates a junction table and requires a matching inverse on the target entity (see Many-to-Many in the syntax reference).onSoftDeletecascades soft-deletes to children (valid onhasMany/hasOneonly, requiresSoftDeletableon both entities). See Self-Referential Tree Helpers below for tree behavior and thenoTreemodifier. - Computed fields:
:=defines derived values - Lifecycle hooks:
afterCreate,afterUpdate,afterDeletetrigger side effects - Validation modifiers:
: trim,: unique,: index(a nullable field is written with a?suffix on its type:String? note) - Default values:
= OrderStatus.Pending,= DateTime.now(),= uuid()
When an entity has both a self-referential belongsTo and a self-referential hasMany (e.g. Category with parent and children), the generators emit tree helpers: SQL CHECK constraint (no self-reference), Python service methods (get_ancestors, get_descendants, get_roots, list_by_parent_id), and TypeScript TypeORM tree decorators (@Tree('closure-table'), @TreeParent(), @TreeChildren()). Use the noTree modifier on the hasMany to disable this and get plain ManyToOne/OneToMany (e.g. hasMany Employee as directReports : noTree). Semantic rules REL007 (warning) and REL008 (error) enforce consistent use of noTree and self-ref pairs.
Datrix provides a rich type system that maps to concrete types in each target language and database.
String, Text, Integer (also spelled Int), Short, Long, Float, Boolean, Decimal, Bytes
UUID, Email, URL, Phone, IPAddress, CIDR, MAC, JSON, Slug, Money, Percentage, Color, CountryCode, LanguageCode, CurrencyCode, PostalCode, Latitude, Longitude, CreditCard, IBAN, Password
DateTime (UTC), Date, Time, Duration, Timestamp, TimestampMs
Array<T>, Map<K, V>, Set<T>
String(200) limits to 200 characters. Decimal(10,2) sets precision and scale.
String? note — the ? suffix makes a field nullable.
enum OrderStatus {
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled
}
Enums are first-class types used in fields, parameters, and event payloads.
rest_api OrderAPI : basePath('/api/v1/orders'), rdbms(db) {
resource Order : auth(required, providers: [identity]);
resource OrderItem : only(list, get), auth(required, providers: [identity], roles: [admin]);
@path('/search')
get(String text, OrderStatus wanted) : auth(required, providers: [identity]) -> Array<Order> {
return Order.where(status: wanted).all().filter(o => o.description.contains(text));
}
}
resource generates standard CRUD endpoints. Use : only(...) to limit operations. Every resource and endpoint declares its access with a mandatory : auth(...) modifier — auth(public), auth(optional, providers: [...]), auth(required, providers: [...]) (optionally with roles: [...]), auth(service, providers: [...]), or auth(webhook) paired with verify(...). The only other endpoint modifiers are hidden and idempotent. Custom endpoints are declared with their HTTP method (get, post, put, patch, delete).
rest_api and graphql_api blocks support optional storage defaults that define the default storage block for bare entity references inside that API.
rest_api MemberAPI : basePath("/api/v1/members"), rdbms(memberDb) {
@path('/:id')
get(UUID id) : auth(public) -> Member {
return Member.findOrFail(id);
}
}
graphql_api MemberGraphQL : basePath("/graphql"), rdbms(memberDb), auth(public) {
query member(UUID id) -> Member {
return Member.findOrFail(id);
}
}
rest_api CatalogAPI : basePath("/api/v1/catalog"), nosql(catalogStore) {
@path('/:sku')
get(String sku) : auth(public) -> ProductDocument {
return ProductDocument.findOrFail(sku);
}
}
basePath(string)— Required. Base path for the API.rdbms(blockName)— Optional. Default RDBMS block for bare entity references.nosql(blockName)— Optional. Default NoSQL block for bare entity references.
Attributes are comma-separated. An API may declare neither, one, or both storage defaults.
For a storage-entity reference inside a service-owned REST or GraphQL API:
- Explicit
block.Entitywins. If the user writesauditDb.AuditEntry, that is the final binding. - Declaration-level explicit binding wins. If an endpoint or operation has an explicit binding, use that.
- Otherwise, use the API default matching the storage kind. Bare
Memberusesrdbms(memberDb)if declared. - Otherwise, fail. The user must use
block.Entityor declare an API default.
API Default:
service club.MemberService('config/member-service.dcfg') : version('1.0.0') {
rdbms memberDb {
entity Member {
UUID id : primaryKey = uuid();
}
}
rest_api MemberAPI : basePath("/members"), rdbms(memberDb) {
@path('/:id')
get(UUID id) : auth(public) -> Member {
return Member.findOrFail(id);
}
}
}
Member resolves to memberDb.Member.
Explicit Override:
service club.MemberService('config/member-service.dcfg') : version('1.0.0') {
rdbms memberDb {
entity Member { UUID id : primaryKey = uuid(); }
}
rdbms auditDb {
entity AuditEntry { UUID id : primaryKey = uuid(); }
}
rest_api MemberAPI : basePath("/members"), rdbms(memberDb) {
@path('/audit/:id')
get(UUID id) : auth(required, providers: [identity], roles: [auditor]) -> auditDb.AuditEntry {
return auditDb.AuditEntry.findOrFail(id);
}
}
}
auditDb.AuditEntry uses the explicit block and does not consult the API default.
No Default (Error):
rest_api MemberAPI : basePath("/members") {
@path('/:id')
get(UUID id) : auth(public) -> Member {
return Member.findOrFail(id);
}
}
This is invalid when Member is a storage entity. The user must either declare rdbms(memberDb) or write memberDb.Member.
Mixed Defaults Conflict:
rest_api SearchAPI : basePath("/search"), rdbms(sqlDb), nosql(documentStore) {
@path('/:id')
get(UUID id) : auth(public) -> Product {
return Product.findOrFail(id);
}
}
If both sqlDb.Product and documentStore.Product exist, bare Product is invalid. The user must write sqlDb.Product or documentStore.Product.
- Storage defaults are valid only on service-owned APIs.
rest_apiblocks insideextern servicemay not userdbms(...)ornosql(...).- Extern-service REST APIs may not reference storage entities in endpoint signatures, even block-qualified.
service ecommerce.OrderService('config/order-service.dcfg') : version('1.0.0') {
pubsub mq {
topic OrderEvents {
publish OrderCreated(UUID orderId, Money total);
publish OrderShipped(UUID orderId, DateTime shippedAt);
}
}
subscribe OrderEvents {
on OrderCreated(UUID orderId, Money total) {
Log.info("Order created", { orderId: orderId });
}
}
}
Topics, events, publishers, and subscribers are defined declaratively. The compiler validates that every subscribed event has a matching publisher.
Events can declare value-level invariants using ensure clauses:
pubsub mq {
topic OrderEvents {
publish OrderCreated(UUID orderId, Decimal totalAmount, Int itemCount) {
ensure totalAmount > 0;
ensure itemCount > 0;
}
}
}
Contracts are enforced at the publisher side — fail-fast at dispatch, before invalid payloads reach subscribers. See the Event Contracts Guide for full syntax, generated code examples, and design decisions.
The serverless block groups subscribe, job, HTTP endpoints (@path, optional @name('HandlerKey')), and enqueue consumers so the service .dcfg profile can target AWS Lambda, Azure Functions, or the service container without changing handler syntax. See Writing Datrix Applications — Serverless and Grammar Reference — Serverless.
Point-to-point work queues: each message is processed by one worker (competing consumers), with broker semantics for retries, visibility timeout, and DLQ driven by the owning service .dcfg profile (see Configuration Guide — Queue Configuration).
Producer (same service owns the queue declaration and service .dcfg):
queues {
queue ProcessPayment(UUID orderId, Money amount, String currency) {
ensure amount > 0;
}
queue SettlePayment(UUID paymentId, String merchantId, Decimal amount) : fifo(merchantId) {
ensure amount > 0;
}
}
Consumer (another service; add producer to discovery):
enqueue OrderService.ProcessPayment(UUID orderId, Money amount, String currency) {
Log.info("Processing payment", { orderId: orderId });
}
Dispatch (same verb as pubsub — semantic resolution):
dispatch ProcessPayment(orderId, amount, currency);
| Pub/Sub | Queues | |
|---|---|---|
| Delivery | Every subscriber | Exactly one consumer |
| Block | pubsub … { topic … } |
queues { queue … } |
| Declaration | publish Event(…) |
queue Task(…) |
| Consumer | subscribe / on Event (or inside serverless) |
enqueue Service.Task at service scope or inside serverless |
| Verb | dispatch Event(…) |
dispatch Task(…) |
Cross-service enqueue requires the producing service in discovery { } and a single consumer per qualified queue (semantic errors QUE001–QUE003). Queue names and pubsub event names cannot collide on the same service (QUE004). See datrix-validators — Queue.
cache redis {
hash OrderCache : prefix('order:'), ttl(1800) {
UUID orderId : primaryKey;
String customerName;
OrderStatus status;
}
counter OrderViewCount : prefix('order:views');
}
Hash caches and counters are defined with a key prefix and, for a hash, a TTL in seconds.
jobs {
job CleanupExpired {
let Array<db.Order> pending = db.Order.where(status: OrderStatus.Pending).all();
foreach order in pending {
if (order.createdAt < DateTime.now().subtractHours(24)) {
order.status = OrderStatus.Cancelled;
order.save();
}
}
}
}
The schedule (schedule = "0 */6 * * *"), timeout and retry of CleanupExpired are keys of the
jobs config file, never part of the declaration.
cqrs {
view DashboardView {
UUID id : primaryKey;
Int totalOrders;
Money totalRevenue;
Int pendingCount;
}
query GetDashboard(UUID id) -> DashboardView {
return DashboardView.findOrFail(id);
}
command PlaceOrder(UUID customerId, Money total) -> db.Order {
return db.Order.create({ customerId: customerId, total: total });
}
}
storage images {
folder products : path('products/'), allowed(['image/jpeg', 'image/png']), maxSize('5mb');
}
A storage block declares folders; the bucket, provider and credentials handles are keys of the
block's entry in the service .dcfg. Code reaches a folder through the block
(images.products.upload(...), download, delete, exists, list, ...).
discovery {
PaymentService { loadBalance: roundRobin, healthyOnly: true }
}
// Resilience settings resolve from the service .dcfg profile.
Service discovery and resilience (circuit breaker, retry, bulkhead) are configured per service. Generators produce the appropriate client-side implementation.
A discovery entry names a peer service: generators turn it into a typed client addressed by that peer's URL, which the selected platform supplies. A service must not name itself (SVC005) — it is always reachable in-process, and no platform supplies a URL for a service to reach itself. Consuming your own queue or pubsub topic needs no entry either; only a cross-service enqueue does. Two distinct services may discover each other.
Datrix provides built-in objects for common operations, available without imports:
| Object | Purpose |
|---|---|
Auth |
Authentication and authorization |
Cache |
Cache read/write operations |
Crypto |
Hashing, encryption, token generation |
DateTime |
Date/time parsing and formatting |
Email |
Send email notifications |
Http |
HTTP client for external APIs |
JSON |
Parse and serialize JSON |
Log |
Structured logging |
Math |
Mathematical operations |
Queue |
Message queue operations |
Random |
Random value generation |
Request |
HTTP request context |
SMS |
SMS notifications |
Storage |
File/object storage (S3-compatible) |
String |
String manipulation |
Validator |
Input validation helpers |
// Import from a module
from ecommerce.common import BaseEntity, Auditable;
// Module definition — shared across services
module ecommerce.common {
trait Auditable {
String? createdBy;
String? updatedBy;
}
abstract entity BaseEntity {
UUID id : primaryKey, server = uuid();
DateTime createdAt : server = DateTime.now();
DateTime updatedAt : server = DateTime.now();
}
}
Modules provide reusable traits, abstract entities, enums, and structs that can be imported by any service.
Decorators (@name) apply to DSL functions and to REST endpoints (for example @retry, @rateLimit on a rest_api operation). They are not used for server-managed entity fields — those use the server modifier on the field instead (see below).
rest_api OrderAPI : basePath('/api/v1/orders'), rdbms(db) {
@rateLimit(requests: 100, window: 60)
@cache(ttl: 60)
@path('/:id')
get(UUID id) : auth(required, providers: [identity]) -> Order {
return Order.findOrFail(id);
}
}
Rate limiting (@rateLimit(requests: N, window: S), S in whole seconds, at most one per endpoint), response caching (@cache(ttl: S)), deprecation (@deprecated) and a binary route's response media type (@produces('<type>/<subtype>'), only on a route returning Bytes/Bytes?) are decorators; authorization is never a decorator.
Modifiers (: name, …) apply to fields and declarations. server marks a field as server-managed (populate from server defaults / hooks, excluded from client create/update payloads):
rdbms db {
entity Author {
UUID id : primaryKey, server = uuid();
String email : unique, index, trim;
DateTime createdAt : server = DateTime.now();
}
entity Book {
UUID id : primaryKey, server = uuid();
belongsTo Author : index, onDelete(cascade);
}
}
rest_api CatalogAPI : basePath('/api/v1'), rdbms(db) {
resource db.Book : only(list, get), auth(required, providers: [identity], roles: [admin]);
}
test blocks at the service level verify business logic through real execution. They complement auto-generated tests (which verify structure and wiring) by exercising lifecycle hooks, computed fields, validation rules, and event emission.
test("overdue books are flagged on update") {
let db.Book book = db.Book.create({
dueDate: today().subtractDays(1),
status: BookStatus.Available
});
book.title = "Trigger Update";
book.save();
assert book.status == BookStatus.Overdue;
}
test("validation rejects empty title") {
assert throws(() => db.Book.create({ title: "" }), ValidationError("Title is required"));
}
test("afterCreate dispatches BookCreated") {
let db.Book book = db.Book.create({ title: "New Book" });
assert emitted(BookCreated(book.id, book.title));
}
Key features:
assert expression;— Boolean assertion, fails the test if falsethrows(() => call, error)— Verifies that a call raises the expected erroremitted(Event(params))— Verifies that an event was emitted during the test- Real execution —
.create()persists,.save()updates, hooks fire through the service layer - Self-contained — Each test sets up its own data, no shared state
- Cross-block — Tests can reference entities from any
rdbmsblock in the same service
Transpiles to pytest (Python) or Jest (TypeScript) under tests/spec/ / test/spec/. See the Spec Testing Guide for full syntax and best practices.
Declare domain-specific errors with HTTP status codes on module or service. Each entry can include status(N), optional message("…"), and optional structured fields in { … }.
module ecommerce.common {
exceptions {
InvalidCurrencyError : status(400), message("Unsupported currency code");
}
}
service ecommerce.OrderService('config/order-service.dcfg') : version('1.0.0') {
exceptions {
OrderExpiredError : status(410), message("Order has expired and cannot be modified");
InsufficientStockError : status(409), message("Not enough stock available") {
Int availableStock;
Int requestedQuantity;
}
}
}
Semantics: status(N) is required on every declared exception. Optional message("…") and data fields are independent. Module-level and service-level exceptions cannot share a name. Four builtin exception names (EntityNotFoundError, CascadeRestrictionError, ValidationError, RateLimitError) are reserved.
Define constrained aliases on top of existing primitive or domain types inside module or service. Use the new scalar name as a field type elsewhere in the module/service.
module catalog.common {
scalar Price : Float {
min(0);
max(999999);
}
scalar Sku : String(12) {
pattern("[A-Z0-9]+");
}
}
These differ from extension-pack scalars (declared via use extension and DatrixExtension): packs add wholly new types with per-language maps; custom scalars are aliases with constraints. Valid constraint names: min(value), max(value), range(min, max), pattern(regex). Custom scalars are globally visible (no import needed) and compose with collections (Array<Price>, etc.). See the extensions guide for extension-pack scalars.
Extern services declare a contract for an external library or tool that Datrix does not generate code for. You build and deploy the implementation yourself; Datrix generates a typed client in consuming services and wires deployment entries (Docker Compose) automatically.
Use extern service when you need functionality that Datrix doesn't cover natively — domain-specific computation engines (QuantLib, NumPy), third-party API wrappers (Stripe, Salesforce), or data processing tools (PDF generation, image manipulation). The extern service declaration tells Datrix what the external service's API looks like so it can generate type-safe clients for your Datrix services.
extern service <qualified-name>['(' '<config-path>' ')'] [: <attributes>] {
<members>
}
All parts except extern service <qualified.Name> { } are optional:
| Part | Required | Example |
|---|---|---|
| Qualified name | Yes | pricing.PricingEngine |
| Config path | No | 'config/pricing-engine.dcfg' |
| Attributes | No | version('1.0.0'), description('Pricing service') |
| Members | No | structs, enums, rest_api, errors, auth, health |
Extern services support exactly six member types:
| Member | Purpose |
|---|---|
struct |
Value types used in API parameters and responses |
enum |
Enumeration types |
rest_api |
REST endpoint signatures (no implementation bodies) |
errors |
Typed error declarations |
auth |
Authentication configuration |
health |
Health check endpoint |
Infrastructure blocks (rdbms, cache, pubsub, nosql, queues, etc.) are not allowed — extern services are contract declarations only.
Extern service endpoints are signature-only — they declare the HTTP method, parameters, and return type, but no implementation body:
extern service pricing.PricingEngine('config/pricing-engine.dcfg') {
struct PricingRequest { String productId; Int quantity; }
struct PricingResponse { Decimal totalPrice; }
rest_api PricingAPI : basePath('/api/v1') {
post calculatePrice(PricingRequest req) -> PricingResponse;
get getPrice(String productId) -> PricingResponse;
delete clearCache();
}
}
Supported HTTP methods: get, post, put, delete, patch, head, options.
An endpoint with no @path('...') is called at the kebab-case of its name beneath the base path in every language (getPrice is called at /api/v1/get-price). @path('/...') is the one decorator an extern endpoint accepts and overrides that default. Path variables written as {name} must match a declared parameter, and the generated client percent-encodes each value. A generator that cannot realize the declared auth type fails generation.
Extern endpoints can declare precondition contracts using ensure clauses:
extern service pricing.PricingEngine('config/pricing-engine.dcfg') {
struct PricingRequest { String productId; Int quantity; }
struct PricingResponse { Decimal totalPrice; }
rest_api PricingAPI : basePath('/api/v1') {
post calculatePrice(PricingRequest req) -> PricingResponse {
ensure req.quantity > 0;
ensure req.productId != null;
}
get getPrice(String productId) -> PricingResponse {
ensure productId != null;
}
}
}
Ensure clauses generate client-side validation functions that run before making the HTTP request. They fail fast with a ContractViolationError if a precondition is not met.
One auth declaration per extern service, in one of these forms:
auth : apiKey(header: '<header-name>'); // API key in header
auth : bearer(); // Bearer token
auth : serviceJwt(); // Service-to-service JWT
auth : oauth2ClientCredentials; // Client-credentials access token; token URL, credential env-var names and scopes come from the extern service config (auth.oauth2ClientCredentials)
auth : none; // No authentication
extern service pricing.PricingEngine('config/pricing-engine.dcfg') {
errors {
PricingNotFound(String message);
InvalidQuantity(String message, Int quantity);
InternalError();
}
auth : apiKey(header: 'X-API-Key');
health : path('/health');
}
Errors generate typed exception classes in the consuming service's client code.
A Datrix service consumes an extern service with uses:
service ecommerce.OrderService('config/order-service.dcfg') : version('1.0.0') {
uses PricingEngine;
}
Once declared, the service can reference extern service struct and enum types, and the generated code includes a typed HTTP client for calling the extern service's endpoints.
Structs and enums declared inside an extern service are importable by consuming services. When a service declares uses ExternServiceName, the extern service's types become available in that service's scope.
extern service pricing.PricingEngine('config/pricing-engine.dcfg')
: version('1.0.0'), description('External pricing service') {
struct PricingRequest {
String productId;
Int quantity;
}
struct PricingResponse {
Decimal totalPrice;
String currency;
}
enum PricingTier {
STANDARD,
PREMIUM,
ENTERPRISE
}
rest_api PricingAPI : basePath('/api/v1') {
post calculatePrice(PricingRequest req) -> PricingResponse;
get getPrice(String productId) -> PricingResponse {
ensure productId != null;
}
}
errors {
PricingNotFound(String message);
InvalidQuantity(String message, Int quantity);
}
auth : apiKey(header: 'X-API-Key');
health : path('/health');
}
// Consuming service
service ecommerce.OrderService('config/order-service.dcfg') : version('1.0.0') {
uses PricingEngine;
// PricingRequest, PricingResponse and PricingTier are in scope here
rest_api OrderAPI : basePath('/api/v1/orders') {
@path('/tiers')
get() : auth(public) -> Array<PricingTier> {
return [PricingTier.STANDARD, PricingTier.PREMIUM, PricingTier.ENTERPRISE];
}
}
}
For each extern service that a Datrix service consumes via uses:
| Artifact | When Generated |
|---|---|
| Typed HTTP client class | Always |
| Request/response models (Pydantic / TS interfaces) | When structs or enums exist |
| Typed error classes | When errors block exists |
| Contract validation functions | When ensure clauses exist |
| Docker Compose service entry | When config has deployment: container |
| Environment variable injection in consuming services | Always |
See Configuration Guide — Extern Service Configuration for deployment configuration.
Datrix includes a full expression language for business logic inside hooks, jobs, API functions, event handlers, and test blocks:
// Variables are declared with their type
let Decimal total = 0;
// Iteration
foreach item in order.items {
item.subtotal = item.price * item.quantity;
total = total + item.subtotal;
}
// Lambdas
let Array<Decimal> subtotals = order.items.map(i => i.price * i.quantity);
// Conditionals
if (total > 1000) {
order.discount = total * 0.1;
}
// Transactions name the block they scope
transaction(db) {
order.save();
db.Inventory.decrementStock(order.items);
}
Certain declaration types require a non-empty implementation body. If you declare one of these without a body, the Datrix compiler rejects it at compile time with a BODY001 error — no code is generated.
| Declaration type | Body required? | Rationale |
|---|---|---|
| Service function | Yes | A declared function with no body has no purpose |
| Module function | Yes | Same as service function |
| Entity function | Yes | Same as service function |
| Struct function | Yes | Same as service function |
| Trait function | Yes | Trait functions merge into executable entity behavior |
| Lifecycle hook | Yes | Declaring a hook with no body is nonsensical |
| Trait hook | Yes | Trait hooks merge into lifecycle hook behavior |
| Validate block | Yes | Declaring validation with no rules is nonsensical |
| CQRS command handler | Yes | A command that does nothing is an error |
| CQRS query handler | Yes | A query that returns nothing is an error |
| CQRS projection handler | Yes | A projection handler must explicitly react to the event |
| Job handler | Yes | A scheduled job with no action is an error |
| REST endpoint | Derived | An empty body is a derived entity operation, a declared no-op, or BODY003 (see Empty endpoint bodies) |
| GraphQL query / mutation | Yes | An empty resolver would return nothing for its declared result: BODY003 |
| GraphQL subscription | No | A subscription's value may be published from outside its body |
| Test spec | No | Empty tests are placeholders (acceptable) |
| Computed field | N/A | Expression-based, not body-based |
A body is empty if it contains zero statements. A body containing any legal statement — even a single no-op — is non-empty and passes validation.
Error: Function 'calculateTotal' is declared without a body.
All function declarations must include an implementation.
Add a body block or remove the declaration until it is implemented.
Code: BODY001
Location: order_service.dtrx:42
REST endpoints and GraphQL operations are not checked by BODY001; BODY003 governs their empty bodies (below). Test specs with empty bodies are acceptable placeholders.
If a declaration genuinely needs to do nothing (for example, a CQRS projection handler that intentionally ignores an event), you must provide an explicit legal body with valid behavior — such as a logging statement or metric emission. Empty bodies are never valid for required-body declarations. (A REST endpoint that returns nothing is the one declared no-op: see below.)
An empty REST endpoint body means one of three things, decided once from the route's declaration and rendered identically by every language target; a body none of them realizes is BODY003:
- an entity operation on the route's resource entity
E(the entity its return type names, or the one the endpoint declares), which must be an RDBMS entity of the service; - a declared no-op: the route returns
Void(or nothing); - an error,
BODY003, generation never starts.
A row matches only when every declared parameter is consumed: an empty body never silently ignores a parameter that scopes it. A list row declares its one PageRequest parameter and returns Page<E>; the backend's offset/limit query pair is the generator's and is never declared. A derived list always pages, so an empty-body list returning Array<E> is API023.
| Meaning | Method | Path parameters | Other declared parameters | Returns |
|---|---|---|---|---|
| detail | GET, path ends in the key placeholder |
exactly one: E's primary key |
none | E |
| list | GET |
none | exactly one PageRequest |
Page<E> |
| list by field | GET |
exactly one: a non-unique indexed field of E (the path may end in its placeholder, GET /member/:memberId) |
exactly one PageRequest |
Page<E> |
| create | POST |
none | exactly one body parameter of type E |
E |
| update | PUT, PATCH, path ends in the key placeholder |
exactly one: E's primary key |
exactly one body parameter of type E |
E |
| delete | DELETE, path ends in the key placeholder |
exactly one: E's primary key |
none | Void |
| no-op | any | any | any | Void |
The entity rows are tried first, so a matching DELETE deletes rather than doing nothing. A @crossTenant endpoint matches no entity row: its reads are unscoped and the tenant-bound entity service has no unscoped mode. A framework-synthesized route (replay, purge, API-key verification) carries a marker and has its body provided by the generator; it is never checked.
Error: rest_api 'OrderAPI' endpoint 'getOrderByNote' (GET /api/v1/orders/by-note/:note) has an
empty body that no derivation realizes: path parameter 'note' is not the primary key 'id' of
'Order'; an empty body cannot use it. Write the body, or declare the route in a shape an empty
body derives: a detail, list, create, update or delete of its resource entity that consumes
every declared parameter, or a route declared -> Void for a no-op.
Code: BODY003
An empty GraphQL query or mutation body has no derivation: it is BODY003 with the message GraphQL query 'x' in api 'y' has an empty body, so its resolver would return nothing for its declared result. An empty subscription body is legal.
A callable whose declared return type carries a value (anything but Void) must end every path in return <value> or throw; a body that can fall off its end is BODY002, and so is a bare return; inside one. The rule applies to service, REST-API, WebSocket-API, entity, struct, trait, module and shared-block functions; REST and serverless endpoints; GraphQL queries, mutations and subscriptions; CQRS commands and queries; agent tools and prompts; and a WebSocket message that declares replies <Event> (whose empty body is checked too, as are the empty bodies of an agent tool and of a serverless endpoint with a return type). Jobs, subscription and enqueue handlers, projection handlers, lifecycle hooks, validate blocks and tests are void by construction.
A statement list terminates when some statement in it terminates:
| Statement | Terminates when |
|---|---|
return <expr>, throw |
always |
return; |
never (and itself BODY002 in a value-returning body) |
if / else if / else |
an else exists and every branch terminates |
switch |
a default exists and every case and the default terminate |
try / catch / finally |
the finally terminates, or the try and every catch terminate |
transaction(db) { }, tenant(expr) { } |
its body terminates |
while (true) |
its body has no break bound to this loop (a break inside a nested loop does not count) |
any other while, for, foreach |
never: the body may run zero times |
| every other statement | never |
The analysis is intraprocedural: a call to a function that always throws does not terminate its caller.