All module names MUST use kebab-case (lowercase with hyphens).
- Correct:
file-parser,simple-user-settings,api-gateway,types-registry - Incorrect:
file_parser(snake_case),FileParser(PascalCase),fileParser(camelCase)
This naming convention is enforced at multiple levels:
- Folder names: Validated by
make validate-module-names(runs in CI, blocks compilation) - Module attribute: Enforced by the
#[modkit::module]macro at compile time
Module names:
- Must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)
- Must start with a lowercase letter
- Must not end with a hyphen
- Must not contain consecutive hyphens or underscores
Place each module under modules/<name>/:
modules/<name>/
├─ <name>-sdk/ # public API surface for consumers (traits, models, errors)
│ ├─ Cargo.toml
│ └─ src/
│ ├─ lib.rs
│ ├─ client.rs|api.rs # ClientHub trait(s)
│ ├─ models.rs # transport-agnostic models (no REST specifics)
│ └─ errors.rs # transport-agnostic errors
└─ <name>/ # module implementation
├─ Cargo.toml
└─ src/
├─ lib.rs # re-exports SDK types + module struct
├─ module.rs # main struct + Module/Db/Rest/Stateful impls
├─ config.rs # typed config (optional)
├─ api/
│ └─ rest/
│ ├─ dto.rs # HTTP DTOs (serde/utoipa) — REST-only types
│ ├─ handlers.rs # Axum handlers (web controllers)
│ └─ routes.rs # route & OpenAPI registration (OperationBuilder)
├─ domain/ # internal business logic
└─ infra/ # “low-level”: DB, system, IO, adapters
└─ storage/
├─ entity.rs # e.g., SeaORM entities / SQL mappings
├─ mapper.rs # entity <-> SDK conversions (From impls)
└─ migrations/
├─ mod.rs
└─ initial_001.rs
- Handlers may call
domain::servicedirectly. - For simple internal modules you may re-export domain models via the module crate
lib.rs. - Module crates host local client adapters that implement SDK traits; consumers resolve them via
ClientHub. - Infra uses SeaORM via the secure ORM layer (
SecureConn) to enforce scoping. Modules cannot access raw database connections—they provide migration definitions that the runtime executes.
Use the following naming matrix for data types across layers:
| Operation | DB Layer (SeaORM)src/infra/storage/entity/ |
Domain Layer (SDK)<module>-sdk/src/models.rs |
API Request (in)src/api/rest/dto.rs |
API Response (out)src/api/rest/dto.rs |
|---|---|---|---|---|
| Create | ActiveModel | NewUser | CreateUserRequest | UserResponse |
| Read/Get by id | UserEntity | User | Path params (id) | UserResponse |
| List/Query | UserEntity (rows) | User (Vec/iterator) | ListUsersQuery (filter+page) | UserListResponse or Page<UserView> |
| Update (PUT, full) | UserEntity (update query) | UpdatedUser (optional) | UpdateUserRequest | UserResponse |
| Patch (PATCH, partial) | UserPatchEntity (optional) | UserPatch | PatchUserRequest | UserResponse |
| Delete | (no payload) | DeleteUser (optional command) | Path params (id) | NoContent (204) or DeleteUserResponse (rare) |
| Search (text) | UserSearchEntity (projection) | UserSearchHit | SearchUsersQuery | SearchUsersResponse (hits + meta) |
| Projection/View | UserAggEntity / UserSummaryEntity | UserSummary | (n/a) | UserSummaryView |
Notes:
- Keep all transport-agnostic types in the SDK crate. Handlers and DTOs must not leak into the SDK.
- SeaORM entities live in
src/infra/storage/entity/folder (one file per entity). Repository implementation goes insrc/infra/storage/repo.rs. - All REST DTOs live in
src/api/rest/dto.rs; provideFromconversions indto.rsor an optionalmapper.rs.
Purpose: Transport-agnostic public API for consumers. Only one dependency needed.
//! <YourModule> SDK
//!
//! This crate provides the public API:
//! - `<YourModule>Client` trait for inter-module communication
//! - Model types (`User`, `NewUser`, etc.)
//! - Error type (`<YourModule>Error`)
//!
//! Consumers obtain the client from `ClientHub`:
//! ```ignore
//! let client = hub.get::<dyn YourModuleClient>()?;
//! ```
#![forbid(unsafe_code)]
pub mod api;
pub mod errors;
pub mod models;
// Re-export main types at crate root
pub use api::YourModuleClient;
pub use errors::YourModuleError;
pub use models::{NewUser, User, UserPatch, UpdateUserRequest};#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: Uuid,
pub tenant_id: Uuid,
pub email: String,
pub display_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewUser {
pub id: Option<Uuid>,
pub tenant_id: Uuid,
pub email: String,
pub display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UserPatch {
pub email: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateUserRequest {
pub id: Uuid,
pub patch: UserPatch,
}#[derive(Error, Debug, Clone)]
pub enum UsersInfoError {
#[error("User not found: {id}")]
NotFound { id: Uuid },
#[error("User with email '{email}' already exists")]
Conflict { email: String },
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Internal error")]
Internal,
}
// Convenience constructors
impl UsersInfoError {
pub fn not_found(id: Uuid) -> Self { Self::NotFound { id } }
pub fn conflict(email: String) -> Self { Self::Conflict { email } }
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation { message: message.into() }
}
pub fn internal() -> Self { Self::Internal }
}use async_trait::async_trait;
use modkit_security::SecurityContext;
use uuid::Uuid;
use crate::{
errors::UsersInfoError,
models::{NewUser, UpdateUserRequest, User},
};
use modkit_odata::{ODataQuery, Page};
/// Public API trait for users-info module.
///
/// All methods require SecurityContext for authorization.
/// Obtain via ClientHub: `hub.get::<dyn UsersInfoClientV1>()?`
#[async_trait]
pub trait UsersInfoClientV1: Send + Sync {
/// Get a user by ID
async fn get_user(&self, ctx: &SecurityContext, id: Uuid) -> Result<User, UsersInfoError>;
/// List users with cursor-based pagination
async fn list_users(
&self,
ctx: &SecurityContext,
query: ODataQuery,
) -> Result<Page<User>, UsersInfoError>;
/// Create a new user
async fn create_user(
&self,
ctx: &SecurityContext,
new_user: NewUser,
) -> Result<User, UsersInfoError>;
/// Update a user
async fn update_user(
&self,
ctx: &SecurityContext,
req: UpdateUserRequest,
) -> Result<User, UsersInfoError>;
/// Delete a user by ID
async fn delete_user(&self, ctx: &SecurityContext, id: Uuid) -> Result<(), UsersInfoError>;
}//! <YourModule> Module Implementation
//!
//! The public API is defined in `<your-module>-sdk` and re-exported here.
// === PUBLIC API (from SDK) ===
pub use <your_module>_sdk::{
YourModuleClient, YourModuleError,
User, NewUser, UserPatch, UpdateUserRequest,
};
// === MODULE DEFINITION ===
pub mod module;
pub use module::YourModule;
// === INTERNAL MODULES ===
#[doc(hidden)]
pub mod api;
#[doc(hidden)]
pub mod config;
#[doc(hidden)]
pub mod domain;
#[doc(hidden)]
pub mod infra;#[modkit::module(
name = "my_module",
deps = ["foo", "bar"], // api-gateway dependency will be added automatically for rest module capability
capabilities = [db, rest, stateful, /* rest_host if you own the HTTP server */],
client = my_module_sdk::MyModuleApi,
ctor = MyModule::new(),
lifecycle(entry = "serve", stop_timeout = "30s", await_ready)
)]
pub struct MyModule {
/* fields */
}Clients must be registered explicitly in init(): ctx.client_hub().register::<dyn my_module_sdk::MyModuleApi>(api).
All struct and enum types in domain/ must have the #[domain_model] attribute from modkit_macros.
The #[domain_model] proc-macro attribute enforces Domain-Driven Design (DDD) boundaries at compile time:
- Validates field types — scans all fields (including nested generics like
Option<T>,Vec<T>,Box<dyn Trait<T>>) and rejects infrastructure types. - Implements
DomainModeltrait — marks the type asimpl modkit::domain::DomainModel, which can be used for downstream type constraints. - Works on structs and enums — supports named fields, tuple fields, unit variants, and generics.
Without compile-time enforcement, infrastructure types (database connections, HTTP extractors, file handles) can leak into the domain layer, coupling business logic to specific frameworks. The macro catches these violations immediately during cargo check / cargo build, before code reaches CI.
use modkit_macros::domain_model;
#[domain_model]
pub struct Service {
pub(super) repo: Box<dyn UserRepository>,
}
#[domain_model]
pub enum DomainError {
NotFound { id: Uuid },
Validation { message: String },
}The macro rejects the following infrastructure types in field positions:
| Category | Forbidden crates / paths | Examples |
|---|---|---|
| Database frameworks | sqlx, sea_orm |
sqlx::PgPool, sea_orm::DatabaseConnection |
| HTTP / Web frameworks | http, axum, hyper |
http::StatusCode, axum::extract::Request |
| External service clients | reqwest, tonic |
reqwest::Client, tonic::Request |
| File system | std::fs, tokio::fs |
std::fs::File, tokio::fs::File |
| DB-specific type names | — | PgPool, MySqlPool, SqlitePool, DatabaseConnection (any path) |
Forbidden types are also caught inside generics: Option<http::StatusCode>, Vec<sea_orm::Value>, Box<dyn Iterator<Item = http::StatusCode>> are all rejected.
Standard library types, domain types, and SDK types are allowed:
String,i32,bool,Uuid,DateTime<Utc>Vec<T>,Option<T>,HashMap<K, V>,Arc<T>,Box<T>std::collections::*,std::sync::*(onlystd::fsis forbidden)- SDK trait objects:
Box<dyn UserRepository>,Arc<dyn MyClient> - Your own domain types:
domain::Request,domain::StatusCode
If a forbidden type is used, the compiler produces a clear, actionable error:
error: field 'pool' has type 'sqlx::PgPool' which is forbidden (crate 'sqlx').
Domain models must be free of infrastructure dependencies like
database types (sqlx, sea_orm) or HTTP types (http, axum, hyper).
Move infrastructure types to the infra/ or api/ layers.
- Required on: all
structandenumtypes in files under*/domain/paths. - Not needed on: SDK models (
<module>-sdk/src/models.rs), REST DTOs (api/rest/dto.rs), SeaORM entities (infra/storage/entity.rs).
The DE0309 lint runs in CI and denies any struct or enum in domain/ that is missing the #[domain_model] attribute. This ensures the macro cannot be accidentally omitted.
use chrono::{DateTime, Utc};
use modkit_odata_macros::ODataFilterable;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
/// REST DTO for user representation with OData filtering
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, ODataFilterable)]
pub struct UserDto {
#[odata(filter(kind = "Uuid"))]
pub id: Uuid,
#[odata(filter(kind = "Uuid"))]
pub tenant_id: Uuid,
#[odata(filter(kind = "String"))]
pub email: String,
pub display_name: String,
#[odata(filter(kind = "DateTimeUtc"))]
pub created_at: DateTime<Utc>,
#[odata(filter(kind = "DateTimeUtc"))]
pub updated_at: DateTime<Utc>,
}use modkit_db_macros::Scopable;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Scopable)]
#[sea_orm(table_name = "users")]
#[secure(
tenant_col = "tenant_id",
resource_col = "id",
no_owner,
no_type
)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub tenant_id: Uuid,
pub email: String,
pub display_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}The local client adapter bridges the domain service to the SDK API trait. It implements the SDK trait and forwards calls to domain service methods.
Location: src/domain/local_client.rs (or a local_client/ subdirectory for multi-module clients).
Rules:
- Implements the SDK API trait (
<module>_sdk::api::YourModuleClient) - Imports types from the SDK, not from a local
contractmodule - Delegates all calls to the domain
Service - Passes
SecurityContextdirectly to service methods - Converts
DomainErrorto SDK<Module>ErrorviaFromimpl
// src/domain/local_client.rs
use modkit_macros::domain_model;
use async_trait::async_trait;
use std::sync::Arc;
use uuid::Uuid;
use your_module_sdk::{
api::YourModuleClientV1,
errors::YourModuleError,
models::{NewUser, UpdateUserRequest, User},
};
use crate::domain::service::Service;
use modkit_odata::{ODataQuery, Page};
use modkit_security::SecurityContext;
#[domain_model]
pub struct YourModuleLocalClient {
service: Arc<Service>,
}
impl YourModuleLocalClient {
pub fn new(service: Arc<Service>) -> Self {
Self { service }
}
}
#[async_trait]
impl YourModuleClientV1 for YourModuleLocalClient {
async fn get_user(&self, ctx: &SecurityContext, id: Uuid) -> Result<User, YourModuleError> {
self.service
.get_user(ctx, id)
.await
.map_err(Into::into) // DomainError -> YourModuleError via From impl
}
// ... other methods follow the same pattern
}Every new module MUST be registered in two places to be discoverable at runtime:
[dependencies]
# ... existing dependencies
your_module = { package = "cf-your-module", path = "../../modules/your-module/your-module" }#![allow(unused_imports)]
use api_gateway as _;
use your_module as _; // ensures inventory discovers the module at link timeWhy this is required:
- The
inventorycrate discovers modules at link time - Without importing the module, it won't be linked into the binary
- This results in missing API endpoints and the module won't be initialized
After registration, rebuild and verify at http://127.0.0.1:8087/docs.
Every module with REST endpoints SHOULD include a QUICKSTART.md file with:
- Module description — brief explanation of what the module does
- Features/capabilities — bulleted list of key functionality
- Link to /docs — reference to full API documentation
- 1-2 minimal examples — basic curl commands showing typical usage
Template:
# <Module Name> - Quickstart
<2-3 sentence description of what the module does and its purpose.>
**Features:**
- Key capability 1
- Key capability 2
Full API documentation: <http://127.0.0.1:8087/docs>
## Examples
### List Resources
```bash
curl -s http://127.0.0.1:8087/<module>/v1/resource | python3 -m json.tool
```
For additional endpoints, see <http://127.0.0.1:8087/docs>.Key principles:
- Avoid duplication —
/docsis auto-generated and always current - Show, don't list — 1-2 working examples, not comprehensive tables
- Describe stable features — capabilities that won't change frequently
- Create
<module>-sdkcrate withapi.rs,models.rs,errors.rs,lib.rs. - Create
<module>crate withmodule.rs,api/rest/,domain/,infra/storage/. - Implement SDK trait with
async_traitandSecurityContextfirst param. - Add
#[domain_model]on allstruct/enumtypes indomain/(importmodkit_macros::domain_model). - Add
#[derive(ODataFilterable)]on REST DTOs (importmodkit_odata_macros::ODataFilterable). - Add
#[derive(Scopable)]on SeaORM entities (importmodkit_db_macros::Scopable). - Use
SecureConn+SecurityContextfor all DB operations. - Register client in
init():ctx.client_hub().register::<dyn MyModuleApi>(api). - Export SDK types from module crate
lib.rs. - Register module in
apps/cyberware-example-server/Cargo.tomlandregistered_modules.rs.