Skip to content

Latest commit

 

History

History
1022 lines (766 loc) · 40 KB

language-reference.md

File metadata and controls

1022 lines (766 loc) · 40 KB

Datrix Language Reference

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.


File Structure

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.


Comments

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.


Services

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

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 BaseEntity inherits fields from abstract entities
  • Traits: with Auditable mixes in reusable field groups
  • Relationships: belongsTo, hasMany, hasOne, and manyToMany define entity relationships. manyToMany generates a junction table and requires a matching inverse on the target entity (see Many-to-Many in the syntax reference). onSoftDelete cascades soft-deletes to children (valid on hasMany/hasOne only, requires SoftDeletable on both entities). See Self-Referential Tree Helpers below for tree behavior and the noTree modifier.
  • Computed fields: := defines derived values
  • Lifecycle hooks: afterCreate, afterUpdate, afterDelete trigger 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()

Self-Referential Tree Helpers

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.


Type System

Datrix provides a rich type system that maps to concrete types in each target language and database.

Primitives

String, Text, Integer (also spelled Int), Short, Long, Float, Boolean, Decimal, Bytes

Domain Types

UUID, Email, URL, Phone, IPAddress, CIDR, MAC, JSON, Slug, Money, Percentage, Color, CountryCode, LanguageCode, CurrencyCode, PostalCode, Latitude, Longitude, CreditCard, IBAN, Password

Temporal Types

DateTime (UTC), Date, Time, Duration, Timestamp, TimestampMs

Collections

Array<T>, Map<K, V>, Set<T>

Sized Types

String(200) limits to 200 characters. Decimal(10,2) sets precision and scale.

Nullability

String? note — the ? suffix makes a field nullable.


Enums

enum OrderStatus {
    Pending,
    Confirmed,
    Shipped,
    Delivered,
    Cancelled
}

Enums are first-class types used in fields, parameters, and event payloads.


REST APIs

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).

API Storage Defaults

rest_api and graphql_api blocks support optional storage defaults that define the default storage block for bare entity references inside that API.

Syntax

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);
    }
}

Attributes

  • 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.

Resolution Rules

For a storage-entity reference inside a service-owned REST or GraphQL API:

  1. Explicit block.Entity wins. If the user writes auditDb.AuditEntry, that is the final binding.
  2. Declaration-level explicit binding wins. If an endpoint or operation has an explicit binding, use that.
  3. Otherwise, use the API default matching the storage kind. Bare Member uses rdbms(memberDb) if declared.
  4. Otherwise, fail. The user must use block.Entity or declare an API default.

Examples

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.

Restrictions

  • Storage defaults are valid only on service-owned APIs.
  • rest_api blocks inside extern service may not use rdbms(...) or nosql(...).
  • Extern-service REST APIs may not reference storage entities in endpoint signatures, even block-qualified.

Event-Driven Messaging

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.

Event Contracts

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.

Serverless deployment boundary

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.


Queues (task dispatch)

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.


Caching

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.


Background Jobs

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

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

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, ...).


Service Discovery and Resilience

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.


Built-in Objects

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

Imports and Modules

// 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 and Modifiers

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]);
}

Specification-Level Tests

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 false
  • throws(() => call, error) — Verifies that a call raises the expected error
  • emitted(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 rdbms block 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.


Exceptions catalog

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.


Custom scalar types

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

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.

Purpose

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.

Declaration Syntax

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

Allowed Members

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.

REST API Endpoints

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.

Ensure Clauses

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.

Authentication

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

Health Check and Error Declarations

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.

Consuming Extern Services

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.

Type Visibility

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.

Complete Example

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];
        }
    }
}

What Gets Generated

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.


Expression Language

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);
}

Required Implementation Bodies

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

What counts as empty

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 format

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

Exempt declarations

REST endpoints and GraphQL operations are not checked by BODY001; BODY003 governs their empty bodies (below). Test specs with empty bodies are acceptable placeholders.

Intentional no-ops

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.)

Empty endpoint bodies

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.

Every value-returning body returns

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.