Skip to main content

Domain Types

Define your domain model once. Use it everywhere.

Domain types are the core feature of Typr Bridge. Each domain type is grounded in a primary source (usually a database table) and can be aligned to other source entities. Typr generates the type plus all conversion methods automatically.

The Problem​

You have multiple representations of the same concept:

// From PostgreSQL (generated by Typr DB)
public record CustomerRow(CustomerId id, FirstName firstName, LastName lastName,
Email email, Instant createdAt, Instant updatedAt) {}

// From MariaDB (generated by Typr DB)
public record CustomersRow(CustomerId customerId, FirstName firstName, LastName lastName,
Email email) {}

// From your API (generated by Typr API)
public record CustomerDto(CustomerId id, FirstName firstName, LastName lastName, Email email) {}

Same concept, three types. You write the mapping code:

// You write this. For every entity. Forever.
Customer fromPostgres(CustomerRow row) {
return new Customer(row.id(), row.firstName(), row.lastName(), row.email());
}

Customer fromMariadb(CustomersRow row) {
return new Customer(row.customerId(), row.firstName(), row.lastName(), row.email());
}

CustomerDto toApi(Customer c) {
return new CustomerDto(c.id(), c.firstName(), c.lastName(), c.email());
}

The Solution​

Define a domain type grounded in a primary source, with alignments to other sources:

domainTypes:
Customer:
primary: postgres:sales.customer # The anchor source
fields:
id: CustomerId
firstName: FirstName
lastName: LastName
email: Email? # ? means optional/nullable
alignedSources:
mariadb:customers: superset # Aligned to another DB
api:Customer: exact # Aligned to API contract

Typr generates your domain type with all converters:

public record Customer(
CustomerId id,
FirstName firstName,
LastName lastName,
Optional<Email> email
) {
// From sources
public static Customer fromPostgres(CustomerRow row) {
return new Customer(row.id(), row.firstName(), row.lastName(), row.email());
}

public static Customer fromMariadb(CustomersRow row) {
return new Customer(row.customerId(), row.firstName(), row.lastName(), row.email());
}

// To sources
public CustomerRowUnsaved toPostgresUnsaved(Instant createdAt, Instant updatedAt) {
return new CustomerRowUnsaved(id, firstName, lastName, email, createdAt, updatedAt);
}

public CustomerDto toApi() {
return new CustomerDto(id, firstName, lastName, email);
}
}

Your service becomes trivial:

public CustomerDto getCustomer(CustomerId id) {
CustomerRow row = customerRepo.selectById(id, conn);
return Customer.fromPostgres(row).toApi();
}

Defining Domain Types​

Basic Structure​

domainTypes:
TypeName:
primary: source:entity # The anchor source (optional)
fields:
fieldName: FieldType
optionalField: FieldType? # ? = optional
alignedSources: # Other sources aligned to this type
source:entity: mode

Primary Source​

The primary source is the anchor entity that your domain type is based on. It determines the canonical field names and types:

domainTypes:
Customer:
primary: postgres:sales.customer
fields: { ... }

When you specify a primary source:

  • Fields are inferred from that source if not explicitly defined
  • Type compatibility is validated against the primary
  • The primary source always has implicit superset alignment

Fields​

Fields define the canonical shape of your domain type:

fields:
id: CustomerId # Your field type (see Field Types)
firstName: FirstName # Your field type
count: Int # Built-in type
active: Boolean # Built-in type
score: BigDecimal # Built-in type
email: Email? # ? makes it optional (nullable)

Built-in types: String, Int, Long, Double, Boolean, BigDecimal, UUID, Instant, LocalDate, LocalDateTime

Optional fields: Add ? suffix to make a field nullable. This generates Optional<T> in Java, Option[T] in Scala, and T? in Kotlin.


Aligned Sources​

Aligned sources map your domain type to entities in other sources beyond the primary.

Aligned Source Syntax​

alignedSources:
source:entity: mode

Where:

  • source - The name of your data source (from sources: section)
  • entity - The table, view, or model name in that source
  • mode - How the fields align (exact, superset, or subset)

Alignment Modes​

ModeMeaningUse Case
exactFields must match exactlyAPI contracts, strict validation
supersetSource may have additional fieldsDatabase tables (audit columns, timestamps)
subsetSource may have fewer fieldsRead-only views, summary endpoints

superset is the most common mode for databases, since tables typically have extra columns like created_at, updated_at, or audit fields that aren't part of your domain model.

exact is typical for API contracts where you want strict alignment.

subset is useful for views or summary responses that omit some fields.

Examples​

domainTypes:
Customer:
primary: postgres:sales.customer # Primary is always implicitly superset
fields: { ... }
alignedSources:
# Legacy database with different naming
mariadb:legacy.customers:
mode: superset
mappings:
id: cust_id
email: electronic_mail

# API must match exactly
api:Customer: exact

# Summary endpoint omits some fields
api:CustomerSummary: subset

Field Alignment​

Typr automatically aligns fields between your domain type and each aligned source using normalized names.

Automatic Alignment​

Fields align automatically when names match after normalization:

Domain Field     PostgreSQL          MariaDB            API
────────────────────────────────────────────────────────────────
id β†’ customer_id β†’ customer_id β†’ id
firstName β†’ first_name β†’ first_name β†’ firstName
lastName β†’ last_name β†’ last_name β†’ lastName
email β†’ email β†’ email β†’ email

Typr normalizes names by removing underscores and comparing case-insensitively, so firstName, first_name, and FirstName all match.

Explicit Field Mappings​

When names don't align automatically, specify explicit mappings:

alignedSources:
mariadb:legacy.customers:
mode: superset
mappings:
id: cust_identifier # Domain 'id' maps to 'cust_identifier'
email: electronic_mail # Domain 'email' maps to 'electronic_mail'

Excluding Fields​

To exclude certain source fields from a superset alignment:

alignedSources:
postgres:sales.customer:
mode: superset
exclude: [internal_notes, admin_flags]

Including Extra Fields​

For toSource converters when the source requires additional fields:

alignedSources:
postgres:sales.customer:
mode: superset
includeExtra: [createdAt, updatedAt]

This generates converter methods that require these extra fields as parameters:

public CustomerRowUnsaved toPostgresUnsaved(Instant createdAt, Instant updatedAt) {
return new CustomerRowUnsaved(id, firstName, lastName, email, createdAt, updatedAt);
}

Compatibility Validation​

Typr validates that your domain type is compatible with all aligned sources at generation time:

Domain type 'Customer' has incompatible aligned sources:

Field 'id':
mariadb:legacy_customers.cust_id β†’ String (expected Long from primary)

Fix: Ensure all sources use compatible underlying types, or use
a custom field type to bridge the difference

Schema mismatches become compile-time errors, not production bugs.


Generated Code​

The Domain Type​

Typr generates a domain type record/data class/case class:

/**
* Domain type 'Customer'
* Primary: postgres:sales.customer
* Aligned: mariadb:customers (superset), api:Customer (exact)
*/
public record Customer(
CustomerId id,
FirstName firstName,
LastName lastName,
Optional<Email> email
) {
// Converters...
}

From-Source Converters​

For the primary and each aligned source, Typr generates a fromSource method:

public static Customer fromPostgres(CustomerRow row) { ... }
public static Customer fromMariadb(CustomersRow row) { ... }

To-Source Converters​

For non-readonly aligned sources, Typr generates toSource methods:

// exact mode - direct conversion
public CustomerDto toApi() { ... }

// superset mode - requires extra fields
public CustomerRowUnsaved toPostgresUnsaved(Instant createdAt, Instant updatedAt) { ... }

Readonly Sources​

Mark an aligned source as readonly to skip generating toSource converters:

alignedSources:
postgres:sales.customer_view:
mode: subset
readonly: true

Complete Example​

domainTypes:
Customer:
primary: postgres:sales.customer # Primary source
fields:
id: CustomerId
firstName: FirstName
lastName: LastName
email: Email?
active: Boolean
alignedSources:
postgres:sales.customer: # Can also specify primary explicitly with options
includeExtra: [createdAt, updatedAt]
mariadb:legacy.customers:
mode: superset
mappings:
id: cust_id
active: is_active
api:Customer: exact
api:CustomerSummary:
mode: subset
readonly: true

Order:
primary: postgres:sales.order
fields:
id: OrderId
customerId: CustomerId
total: BigDecimal
status: OrderStatus
alignedSources:
api:Order: exact

Using with Field Types​

Domain types work best when combined with Field Types. Field types give you type safety at the individual field level (CustomerId vs OrderId), while domain types give you type safety at the record level (Customer vs CustomerRow).

# Define field types for type safety
fieldTypes:
CustomerId:
db: { column: [customer_id], primary_key: true }
model: { name: [customerId] }
FirstName:
db: { column: [first_name, firstname] }
model: { name: [firstName] }

# Use them in domain types
domainTypes:
Customer:
fields:
id: CustomerId # Can't accidentally pass an OrderId
firstName: FirstName # Can't accidentally pass a LastName
...

Next Steps​