Programming Languages
Welcome to the Languages section of the Dita Engineering Guidelines. Here you will find specific standards, best practices, and tooling requirements for the programming languages we use.
Supported Languages
- Rust - Guidelines for writing safe, concurrent, and practical systems in Rust.
Goals of this section:
- Ensure consistency across codebases.
- Promote language-specific idiomatic practices.
- Define standard tooling and configuration (linters, formatters).
Rust Engineering Guidelines
This section details the standards and best practices for Rust development at Dita. Our goal is to leverage Rust's safety guarantees while maintaining readable and maintainable code.
Core Principles
- Safety First: Avoid
unsafecode unless absolutely necessary and strictly isolated/documented. - Idiomatic Rust: Follow standard conventions. If "fighting the borrow checker", reconsider the design.
- Documentation: All public APIs must be documented.
Tooling
- Formatter: We use
rustfmtwith the standard configuration. - Linter: We use
clippy. CI pipelines should fail on clippy warnings.
Topics
- Project Structure
- Service Configuration
- Service Bootstrap
- Error Handling (To be added)
- Testing (To be added)
- Async/Await (To be added)
Rust project structure — rust-project-structure.md
Below is a recommended repository layout for a Rust service that follows Clean Architecture principles and a functional-programming-friendly style. After the tree I describe the purpose and accepted contents of each entry so maintainers and contributors know what belongs where.
/ # repo root
├── .github/ # CI/CD pipelines
│ └── workflows/
│ ├── common.yml
│ ├── feature.yml
│ ├── dev.yml
│ ├── master.yml
│ └── release.yml
│
├── docker/ # Docker build context (multi-stage, test images, compose helpers)
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── README.md
│
├── proto/ # Shared gRPC contracts (.proto files)
│ ├── student.proto
│ ├── instructor.proto
│ └── common.proto
│
├── scripts/ # Dev & Ops helper scripts (migrate, seed, local-run)
│ ├── migrate.sh
│ ├── seed.sh
│ ├── local-run.sh
│ └── README.md
│
├── migrations/ # SQL migrations (for sqlx / refinery)
│ ├── 0001_init.sql
│ ├── 0002_add_instructor_table.sql
│ └── README.md
│
├── src/
│ ├── main.rs # Bootstrap entry: load config, init telemetry, start runtime
│ │
│ ├── bin/ # Optional binaries (e.g., worker, migrate, consumer)
│ │ ├── migrate.rs
│ │ └── consumer.rs
│ │
│ ├── core/ # ❖ Domain & Use Cases — pure, testable, framework-free
│ │ ├── domain/ # Entities, ValueObjects, domain errors, domain events
│ │ │ ├── student.rs
│ │ │ ├── instructor.rs
│ │ │ ├── error.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── ports/ # Abstract traits (repositories, message brokers, cache)
│ │ │ ├── student_repository.rs
│ │ │ ├── instructor_repository.rs
│ │ │ ├── message_broker.rs
│ │ │ ├── error.rs
│ │ │ └── mod.rs
│ │ │
│ │ └── usecases/ # Application layer orchestration
│ │ ├── enroll_student/
│ │ │ ├── command.rs
│ │ │ ├── query.rs
│ │ │ └── tests.rs
│ │ │
│ │ ├── assign_instructor/
│ │ │ ├── command.rs
│ │ │ ├── query.rs
│ │ │ └── tests.rs
│ │ │
│ │ └── mod.rs
│ │
│ ├── adapters/ # ❖ Adapters — implementing external boundaries (infra)
│ │ ├── http/ # Axum controllers + route definitions
│ │ │ ├── routes.rs
│ │ │ └── handlers/
│ │ │ ├── student_handler.rs
│ │ │ ├── instructor_handler.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── grpc/ # tonic server + client implementations
│ │ │ ├── student_grpc.rs
│ │ │ ├── instructor_grpc.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── persistence/ # SQLx repositories (Postgres)
│ │ │ ├── student_repository.rs
│ │ │ ├── instructor_repository.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── kafka/ # rdkafka producers & consumers
│ │ │ ├── producer.rs
│ │ │ ├── consumer.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── cache/ # Redis adapters (deadpool-redis)
│ │ │ ├── redis_cache.rs
│ │ │ └── mod.rs
│ │ │
│ │ ├── auth/ # JWT / OIDC middleware
│ │ │ ├── jwt.rs
│ │ │ └── mod.rs
│ │ │
│ │ └── external/ # third-party HTTP/gRPC integrations
│ │ ├── payment_service.rs
│ │ ├── logging_service.rs
│ │ └── mod.rs
│ │
│ ├── infrastructure/ # ❖ Composition root: wiring + builders + runtime setup
│ │ ├── bootstrap.rs # app startup orchestration
│ │ ├── config-loader.rs # config loader using `config` crate (YAML + env)
│ │ ├── db.rs # Postgres connection builder
│ │ ├── cache.rs # Redis pool builder
│ │ ├── kafka.rs # Kafka setup
│ │ ├── cli.rs # Command-line interface definition and parsing
│ │ ├── server/ # Axum + Tonic server builders
│ │ │ ├── http_server.rs
│ │ │ ├── grpc_server.rs
│ │ │ └── mod.rs
│ │ ├── telemetry/ # tracing, otel, prometheus exporters
│ │ │ ├── tracing.rs
│ │ │ ├── metrics.rs
│ │ │ └── mod.rs
│ │ └── mod.rs
│ │
│ ├── shared/ # Common utils shared across layers (pure helpers)
│ │ ├── error.rs # unified AppError type
│ │ ├── json.rs # serde/json helpers
│ │ ├── time.rs # chrono/date helpers
│ │ ├── config.rs # Application configuration model
│ │ ├── state.rs
│ │ └── result.rs # functional Result combinators / error adapters
│ │
│ ├── tests/ # Integration & E2E tests
│ │ ├── integration/
│ │ │ ├── db_integration_test.rs
│ │ │ ├── kafka_integration_test.rs
│ │ │ └── mod.rs
│ │ └── e2e/
│ │ ├── user_flow_test.rs
│ │ └── mod.rs
│ │
│ └── lib.rs # Optional if the service exposes internal lib functions
│
├── config.yml
├── config-dev.yml
├── config-stage.yml
├── config-prod.yml
├── Cargo.toml
└── README.md
Rust Service Configuration
All DiTA Rust backend services must follow this configuration loading pattern.
Configuration loading must be implemented under the infrastructure layer:
src/
└── infrastructure/
└── config_loader.rs
The implementation must use the config crate for configuration sources and serde for typed deserialization.
Default Configuration
The default configuration must be embedded into the binary using include_str!:
#![allow(unused)] fn main() { static DEFAULT_YAML: &str = include_str!("../../config.yaml"); }
It must be registered as the first configuration source:
#![allow(unused)] fn main() { Config::builder() .add_source(File::from_str(DEFAULT_YAML, FileFormat::Yaml)) }
The default configuration therefore does not depend on the presence of the file at runtime.
Configuration Files
Configuration files are loaded after the embedded defaults.
The environment is determined by APP_ENV:
#![allow(unused)] fn main() { let env = env::var("APP_ENV").unwrap_or_else(|_| "dev".into()); }
If no explicit configuration path is provided, the loader must consider these paths in order:
./config.yml
./config-{APP_ENV}.yml
/etc/{application-name}/config.yml
The application name must be obtained from Cargo:
#![allow(unused)] fn main() { let app_name = env!("CARGO_PKG_NAME"); }
The default paths are optional:
#![allow(unused)] fn main() { File::from(path.to_path_buf()).required(false) }
An explicitly provided configuration path must be required:
#![allow(unused)] fn main() { File::from(path.to_path_buf()).required(true) }
Environment Variables
Environment variables must be loaded after all configuration files:
#![allow(unused)] fn main() { .add_source( Environment::with_prefix("APP") .separator("__") ) }
The APP prefix is common to all services.
Nested configuration fields must use __ as the separator. For example:
APP_DATABASE__HOST=localhost
APP_DATABASE__PORT=5432
corresponds to:
database:
host: localhost
port: 5432
Because environment variables are added last, they have the highest precedence.
The resulting precedence is:
Embedded defaults
↓
Configuration files
↓
Environment variables
Typed Configuration
The final configuration must be deserialized into a service-specific type:
#![allow(unused)] fn main() { #[derive(Debug, Deserialize)] pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, } }
The loader must use a generic deserialization interface:
#![allow(unused)] fn main() { pub fn load<C>(conf_path: &Option<String>) -> C where C: DeserializeOwned + Debug, }
Application and domain code must use the resulting typed configuration rather than reading environment variables directly.
Error Handling
Configuration loading and deserialization must fail fast.
The loader must log failures using tracing before terminating:
#![allow(unused)] fn main() { .inspect_err(|error| { error!("Config Load -> FAILED: error=({})", error) }) .unwrap() }
and:
#![allow(unused)] fn main() { .inspect_err(|error| { error!("Config Deserialize -> FAILED: error=({})", error) }) .unwrap(); }
A service must not start with an invalid or incomplete configuration.
Logging
Successful configuration loading should be logged:
#![allow(unused)] fn main() { info!( "Config Load -> SUCCESS: [path, required]=({:?}), config=({:?})", conf_paths, config ); }
Configuration values must not be logged if their Debug representation can expose secrets such as passwords, tokens, API keys, or private keys.
Implementation
The configuration loader should follow this implementation pattern:
#![allow(unused)] fn main() { use config::{Config, Environment, File, FileFormat}; use serde::de::DeserializeOwned; use std::env; use std::fmt::Debug; use std::path::PathBuf; use tracing::{error, info}; static DEFAULT_YAML: &str = include_str!("../../config.yaml"); pub fn load<C>(conf_path: &Option<String>) -> C where C: DeserializeOwned + Debug, { let conf_paths = get_config_paths(conf_path); let config = conf_paths .iter() .map(|(path, required)| File::from(path.to_path_buf()).required(required.clone())) .fold( Config::builder().add_source(File::from_str(DEFAULT_YAML, FileFormat::Yaml)), |builder, file| builder.add_source(file), ) .add_source(Environment::with_prefix("APP").separator("__")) .build() .inspect_err(|error| error!("Config Load -> FAILED: error=({})", error)) .unwrap() .try_deserialize() .inspect_err(|error| error!("Config Deserialize -> FAILED: error=({})", error)) .unwrap(); info!( "Config Load -> SUCCESS: [path, required]=({:?}), config=({:?})", conf_paths, config ); config } fn get_config_paths(conf_path: &Option<String>) -> Vec<(PathBuf, bool)> { let env = env::var("APP_ENV").unwrap_or_else(|_| "dev".into()); let cwd = env::current_dir() .inspect_err(|error| { error!("Get Current Working Directory -> FAILED. error=({})", error) }) .unwrap(); let app_name = env!("CARGO_PKG_NAME"); match conf_path { None => vec![ (cwd.join("config.yml"), false), (cwd.join(format!("config-{}.yml", env)), false), (cwd.join(format!("/etc/{}/config.yml", app_name)), false), ], Some(path) => vec![(cwd.join(path), true)], } } }
This implementation pattern must be used consistently across Rust backend services.
Rust Service Bootstrap
All DiTA Rust backend services must follow a standardized bootstrap pattern.
The application entry point must remain minimal. Service initialization, command-line parsing, configuration loading, application state construction, router initialization, and server startup must be orchestrated by the infrastructure bootstrap layer.
Architecture
The bootstrap flow must follow this structure:
src/
├── main.rs
├── infrastructure/
│ ├── bootstrap.rs
│ └── cli.rs
└── shared/
├── config.rs
└── state.rs
The responsibilities of these modules are:
| Module | Responsibility |
|---|---|
main.rs | Application entry point |
infrastructure/bootstrap.rs | Application bootstrap orchestration |
infrastructure/cli.rs | Command-line interface definition and parsing |
shared/config.rs | Application configuration model |
shared/state.rs | Shared application state |
Application Entry Point
main.rs must contain only the minimal application entry point and delegate startup to the bootstrap layer.
async fn main() -> AppResult<()> { bootstrap::start().await }
Business logic, configuration parsing, router construction, or infrastructure initialization must not be implemented directly in main.rs.
Command-Line Interface
Application bootstrap configuration must be initiated through a command-line interface implemented with clap.
The CLI definition must reside under the infrastructure layer:
src/
└── infrastructure/
└── cli.rs
The CLI must use clap::Parser:
#![allow(unused)] fn main() { use clap::Parser; #[derive(Parser)] #[command(name = "<app name>")] #[command(about = "<App Description>", long_about = None)] pub struct Cli { #[arg(short, long, global = true)] pub config: Option<String>, // ... } }
Cli::parse() must be the entry point for command-line argument parsing during bootstrap.
Services may expose additional command-line arguments as required by their responsibilities, but CLI options must remain limited to application startup and operational concerns.
Configuration Loading
The parsed CLI arguments must be used to determine the configuration source.
Configuration loading must remain separate from the CLI definition and must use the project's standardized configuration-loading mechanism.
The configuration model must be defined independently:
#![allow(unused)] fn main() { use serde::Deserialize; use std::net::SocketAddr; #[derive(Clone, Debug, Deserialize)] pub struct Config { pub listen_addr: SocketAddr, // ... } }
The bootstrap layer is responsible for connecting the CLI configuration path to the configuration loader:
#![allow(unused)] fn main() { let cli = Cli::parse(); let conf_path = cli.config; let config: Config = config_loader::load(&conf_path); }
The configuration model must not depend on clap, and the CLI model must not contain application runtime state.
Bootstrap Orchestration
All service startup operations must be orchestrated by infrastructure/bootstrap.rs.
The bootstrap function must perform initialization in a defined sequence:
- Parse command-line arguments.
- Load application configuration.
- Initialize application state.
- Initialize application components and infrastructure dependencies.
- Construct the router and middleware.
- Bind the server listener.
- Start the HTTP server.
A typical implementation is:
#![allow(unused)] fn main() { pub async fn start() -> AppResult<()> { telemetry::init(); info!("Starting <service name>..."); let cli = Cli::parse(); let conf_path = cli.config; let config: Config = config_loader::load(&conf_path); let state = AppState { config: config.clone(), // ... }; let app = router() .with_state(Arc::new(state)) .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; info!( "Listening -> SUCCESS: listen_addr=({})", config.listen_addr ); axum::serve(listener, app).await?; info!( "Axum Serve -> SUCCESS: listen_addr=({})", config.listen_addr ); Ok(()) } }
The bootstrap function is responsible for orchestration only. It must not become a container for business logic or domain-specific application behavior.
Shared Application State
Application-wide runtime state must be represented by AppState and defined separately from the bootstrap implementation:
src/
└── shared/
└── state.rs
Example:
#![allow(unused)] fn main() { pub struct AppState { pub config: Config, // ... } }
The bootstrap layer constructs the initial AppState and provides it to the application components that require shared state.
AppState must contain runtime dependencies and shared application data, not command-line parsing concerns.
Dependency Boundaries
The following boundaries must be maintained:
main.rs
│
▼
bootstrap.rs
│
├──► cli.rs
├──► config_loader
├──► Config
├──► AppState
└──► application infrastructure
The CLI, configuration model, and application state have distinct responsibilities:
cli.rsdefines how startup parameters are provided.config.rsdefines the application's runtime configuration.state.rsdefines shared runtime state.bootstrap.rsconnects these components and starts the service.
HTTP Server Startup
HTTP server construction and startup must be performed by the bootstrap layer.
For Axum-based services:
#![allow(unused)] fn main() { let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; axum::serve(listener, app).await?; }
The listener address must originate from application configuration rather than being hard-coded in the bootstrap implementation.
Bootstrap Errors
Bootstrap failures must be propagated through the service's standard application result type:
#![allow(unused)] fn main() { pub async fn start() -> AppResult<()> { // ... } }
Errors encountered while loading configuration, constructing infrastructure, binding the listener, or starting the server must not be silently ignored.
Design Rules
All DiTA Rust backend services must follow these rules:
main.rsmust remain a thin entry point.clapmust be used for command-line parsing.- CLI definitions must reside in
infrastructure/cli.rs. - Bootstrap orchestration must reside in
infrastructure/bootstrap.rs. - Runtime configuration must be represented by a dedicated
Configtype. - Shared runtime dependencies must be represented by
AppState. - Configuration loading must remain separate from CLI parsing.
- Business logic must not be implemented in the bootstrap layer.
- Server addresses and other runtime parameters must come from configuration rather than hard-coded values.
- Bootstrap failures must be propagated through the service's standard error handling mechanism.
This pattern provides a consistent startup contract across all DiTA Rust backend services while keeping the application entry point, CLI, configuration, state management, and infrastructure initialization clearly separated.
Development Guidelines
Architecture Decision Records (ADRs)
Architecture Decision Records (ADRs) are short text documents that capture an important architectural decision made along with its context and consequences. We use ADRs to keep track of the history of our decisions and the reasoning behind them.
File Location
All ADRs must be placed in the doc project.
Naming Convention
ADR files should be named using a sequential number and a short descriptive title, separated by a hyphen. The format is NNNN-title-of-the-decision.md.
Example: 0001-record-architecture-decisions.md
Statuses
An ADR can have one of the following statuses:
- Proposed: The decision is being discussed and has not yet been agreed upon.
- Accepted: The decision has been agreed upon and should be implemented.
- Rejected: The decision was proposed and discussed but was not accepted. The record is kept for historical context.
- Deprecated: The decision was accepted in the past but is no longer applicable or recommended.
- Superseded: The decision has been replaced by a newer decision. The new ADR should reference the superseded one.
Template
Use the following template for new ADRs:
# NNNN. Title of the Decision
Date: YYYY-MM-DD
## Status
[Proposed | Accepted | Rejected | Deprecated | Superseded]
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do and any risks introduced by the change that will need to be mitigated.
How to Submit
- Create a new branch for your ADR.
- Add the new ADR file in doc project.
- Ensure the file name uses the next available number.
- Submit a Pull Request for review.
Git Workflow
This section outlines our Git workflow, integrating branching, committing, and issue management.
- Branching: Create a new branch for each task. See Branching Strategy.
- Committing: Write clear, conventional commit messages. See Commit Guidelines.
- Issues: Link your work to specific issues. See Issue Management.
- ADRs: Reference architectural decisions if applicable. See ADR Linking.
Workflow Steps
- Pick an issue (or create one).
- Create a branch from
dev(for new features) ormaster(for incident fixes). - Implement changes.
- Commit changes following the Commit Guidelines.
- Push branch and open a Pull Request.
- Ensure CI checks (including
commitlint) pass.
Branching Strategy
We use a strategy allowing for parallel development using short-lived branches. The repository has two persistent branches: main and dev.
Core Branches
- master: The production-ready state. Contains stable code.
- dev: The main development branch. All new features are merged here first.
Branch Naming & Strategy
Feature Branches
Used for adding new functionality.
- Source Branch:
dev - Naming Convention: Must include the issue number.
- Format:
feat/issue-<number>/<short-description> - Example:
feat/issue-42/add-login-page
Bug Fix Branches
Used for fixing critical bugs or incidents in production.
- Source Branch:
master(preferred for hotfixes/incidents) - Naming Convention: Must include the incident number.
- Format:
fix/incident-<number>/<short-description> - Example:
fix/incident-808/resolve-memory-leak
Other Branches
For documentation, refactoring, or chores.
- Source Branch:
dev(usually) - Format:
type/<scope>/<short-description>ortype/issue-<number>/<short-description> - Example:
docs/readme/update-setup
Branches should be deleted after merging.
Commit Guidelines
High-quality commit messages are essential for maintaining a healthy codebase. They help new developers understand context, simplify debugging with git blame, and allow for automated changelog generation.
Message Structure
A commit message consists of a Header, Body, and Footer.
<type>(<scope>): <subject>
<body (optional, but recommended)>
<footer (optional)>
1. The Header
The header is mandatory and must be 50 characters or less.
Type
Must be one of the following:
| Type | Description | SemVer Impication |
|---|---|---|
| feat | A new feature | MINOR |
| fix | A bug fix | PATCH |
| docs | Documentation changes | PATCH |
| style | Formatting, missing semi-colons, white-space (no code change) | PATCH |
| refactor | Code change that neither fixes a bug nor adds a feature | PATCH |
| perf | A code change that improves performance | PATCH |
| test | Adding missing tests or correcting existing tests | PATCH |
| build | Changes that affect the build system or external dependencies | PATCH |
| ci | Changes to our CI configuration files and scripts | PATCH |
| chore | Other changes that don't modify src or test files | PATCH |
| revert | Reverts a previous commit | PATCH |
Scope
The scope is optional but recommended. It specifies the "place" of the commit change.
- Examples:
auth,api,ui,database,deps. - Format:
feat(auth): ...
Subject
The subject contains a succinct description of the change.
- Imperative mood: "Add" not "Added", "Fix" not "Fixed".
- No period: Do not end with
.. - LowerCase: First letter is usually lowercase (Conventional Commits doesn't enforce this, but consistency is key. Note: "Seven Rules" says capitalized, Conventional Commits examples often show lowercase. We will follow lowercase to match the
commitlintdefault config unless configured otherwise.). -> Self-correction: The previous file said "Capitalize". "Seven Rules" says Capitalize. Conventional Commits is agnostic. Let's stick to Capitalize as it looks more professional in Git logs, matching the previous rule.
2. The Body
The body is optional but strongly recommended for non-trivial changes.
- Wrap at 72 characters: This ensures readability in all environments.
- Motivation: Explain why you are making this change.
- Contrast: Compare the new behavior with the previous behavior.
- Explanation: Explain what and why, not how (the code explains the how).
3. The Footer
The footer is used for meta-information.
Breaking Changes
All breaking changes have to be mentioned in the footer with the description of the change, justification and migration notes.
- Format: Start with
BREAKING CHANGE: <description> - Alternative: Add
!after the type/scope in the header (e.g.,feat(api)!: ...).
References
- Issues:
Closes #123,Fixes #42. - ADRs:
ADR: 004.
Collaboration
- Co-authored-by:
Co-authored-by: Name <name@example.com>
Examples
Feature with Scope
feat(auth): Add Google OAuth login support
Add support for Google OAuth 2.0 to allow users to sign up
and log in using their Google accounts. This simplifies the
onboarding process.
Closes #101
Bug Fix with Breaking Change
fix(api): Handle null values in user response
Previously, the API crashed when the user had no address.
Now, it returns a null address field instead.
BREAKING CHANGE: The `address` field in the user object
can now be null. Clients must update their code to handle
this case.
Documentation
docs(readme): Update installation instructions
Revert
revert: let us never speak of this again
This reverts commit 6b2a412.
Issue Management
Properly linking Git activity to issues ensures a transparent history and automated workflow states.
Linking in Commits
Reference issues in your commit footer.
Closing Issues
To automatically close an issue when the commit is merged:
Closes #123
Or for multiple issues:
Closes #123, #245
Referencing Issues
To simply reference an issue without closing it (e.g., "See also"):
Refs #123
Linking in Pull Requests
In the description of your Pull Request, you can also use keywords to link issues.
- "Closes #123"
- "Fixes #123"
- "Resolves #123"
This creates a link in GitHub and automatically closes the issue when the PR is merged into the default branch.
ADR Linking
Architecture Decision Records (ADRs) explain the "why" behind significant changes. It is crucial to link code changes to the decisions that authorized them.
Linking ADRs in Commits
If a commit implements a specific ADR, reference it in the footer of the commit message, similar to issues.
Format:
ADR: <ADR-Number>
Example:
feat(database): migrate to postgres
We are migrating to Postgres to support better transaction handling as decided.
Closes #45
ADR: 0012
Linking ADRs in Issues/PRs
When opening an Issue or PR that relates to an architectural decision, include a link to the ADR file in the description.
Example:
Implements ADR-0012: Use Postgres