Skip to content

feat!: clean architecture (DDD) restructure with exposed inbound contract + hardened CI - #13

Merged
bouroo merged 1 commit into
mainfrom
feat/clean-architecture
Aug 30, 2026
Merged

feat!: clean architecture (DDD) restructure with exposed inbound contract + hardened CI#13
bouroo merged 1 commit into
mainfrom
feat/clean-architecture

Conversation

@bouroo

@bouroo bouroo commented Aug 30, 2026

Copy link
Copy Markdown
Member

Summary

Restructures the template to clean architecture (DDD) per the clean-architecture reference, mirroring zercle-go-template's layout, and hardens the GitHub Actions pipeline.

Feature layering (all dependencies point inward)

  • features/example/contract/exposed inbound type contract (leaf; serde+validator only). The published facade crate::api::v1 re-exports these types + error codes for external consumers.
  • features/example/domain/ — entities + sentinel errors (zero crate deps)
  • features/example/port/ — outbound Repository port
  • features/example/application/ — inbound Service port that speaks contract types at the boundary + Usecase impl (owns domain↔contract mapping; single uuid-validation path shared by HTTP + gRPC)
  • features/example/adapter/driving/{http,grpc} + adapter/driven/postgres — interface adapters
  • features/example/di.rs — wiring + sentinel→AppError registration (RegisterSentinel parity)

Platform & composition

  • platform/ replaces shared/ + infrastructure/ + middleware/ + config.rs; feature-agnostic by rule
  • platform::server decoupled from features: AppState + grpc_server() builder live in the shell; features hand over pre-mounted routers
  • app.rs composition root wires telemetry → db → valkey → health → features::example::di::register

Executable architecture gates

  • New tests/architecture.rs scans every use crate::… statement and enforces 8 layering rules (mirrors Go's architecture_test.go; dedicated Architecture CI job
  • Mutation-probe verified: injected violation → red with rule rationale; reverted → green

CI (GitHub Actions)

  • Dedicated Architecture job (fast layering signal)
  • Integration job fix: now runs the FULL suite against the + service containers () — previously the e2e/HTTP tests never ran against them
  • All 6 actions pinned to commit SHAs; on every checkout
  • Taskfile: new , corrected stale invocations, fixed pre-existing YAML plain-scalar bug in the task

Verification

  • → 0
  • → 0

running 92 tests
test features::example::adapter::driven::postgres::tests::live_db_create_and_get_by_id_roundtrip ... ignored
test api::v1::tests::errcode_re_exports ... ok
test features::example::adapter::driven::postgres::tests::row_maps_to_domain_item ... ok
test api::v1::tests::contract_aliases_round_trip_json ... ok
test features::example::adapter::driven::postgres::tests::row_not_found_translates_to_domain_not_found ... ok
test features::example::adapter::driven::postgres::tests::other_sqlx_errors_become_internal ... ok
test features::example::adapter::driving::grpc::tests::get_item_not_found_maps_to_not_found ... ok
test features::example::adapter::driving::grpc::tests::create_item_ok ... ok
test features::example::adapter::driving::grpc::tests::create_item_maps_invalid_name_to_invalid_argument ... ok
test features::example::adapter::driving::grpc::tests::get_item_bad_uuid_maps_to_invalid_argument ... ok
test features::example::adapter::driving::grpc::tests::list_items_ok ... ok
test features::example::adapter::driving::grpc::tests::get_item_ok ... ok
test features::example::application::usecase::tests::create_rejects_empty_name ... ok
test features::example::application::usecase::tests::create_rejects_multibyte_name_over_rune_cap ... ok
test features::example::application::usecase::tests::create_rejects_overlong_name ... ok
test features::example::adapter::driving::http::tests::get_items_by_id_returns_400_on_bad_uuid ... ok
test features::example::adapter::driving::http::tests::get_items_by_id_returns_200_on_hit ... ok
test features::example::adapter::driving::http::tests::get_items_by_id_returns_404_on_missing ... ok
test features::example::adapter::driving::http::tests::post_items_returns_201_on_success ... ok
test features::example::adapter::driving::http::tests::post_items_returns_400_on_invalid_name ... ok
test features::example::adapter::driving::http::tests::get_items_returns_200_with_payload ... ok
test features::example::application::usecase::tests::fallback_defaults_apply_when_config_zero ... ok
test features::example::adapter::driving::http::tests::get_items_returns_400_on_invalid_limit ... ok
test features::example::application::usecase::tests::create_maps_domain_item_to_wire_timestamps ... ok
test features::example::application::usecase::tests::create_accepts_multibyte_name_within_rune_cap ... ok
test features::example::application::usecase::tests::get_passes_through_not_found ... ok
test features::example::application::usecase::tests::create_trims_and_persists ... ok
test features::example::application::usecase::tests::get_rejects_malformed_id_before_touching_the_port ... ok
test features::example::application::usecase::tests::get_returns_wire_response_on_hit ... ok
test features::example::application::usecase::tests::list_clamps_limit_above_max ... ok
test features::example::application::usecase::tests::list_clamps_negative_offset_to_zero ... ok
test features::example::contract::tests::create_request_validates_length ... ok
test features::example::application::usecase::tests::list_uses_default_when_limit_zero_or_missing ... ok
test features::example::contract::tests::list_request_validates_range ... ok
test features::example::di::tests::domain_sentinels_map_to_boundary_codes ... ok
test features::example::domain::item::tests::rename_updates_name_and_timestamp ... ok
test platform::config::tests::leaf_bindings_matches_go_set ... ok
test platform::db::tests::check_pings_real_database ... ignored, requires a live Postgres at localhost:5432 with user=postgres password=postgres db=app
test platform::errors::tests::code_and_message_are_stable ... ok
test platform::errors::tests::grpc_code_per_variant ... ok
test platform::errors::tests::grpc_status_internal_does_not_leak_cause ... ok
test platform::errors::tests::grpc_status_invalid_input_appends_cause ... ok
test platform::errors::tests::grpc_status_not_found_appends_cause ... ok
test platform::errors::tests::http_status_per_variant ... ok
test platform::health::tests::empty_registry_is_ok ... ok
test platform::health::tests::failing_checker_name_surfaces ... ok
test platform::health::tests::passing_checkers_succeed ... ok
test platform::health::tests::mixed_run_concurrently ... ok
test platform::middleware::cors::tests::default_layer_returns_a_layer ... ok
test platform::config::tests::db_conn_string_has_sslmode ... ok
test platform::config::tests::db_conn_string_percent_encodes_special_chars ... ok
test platform::middleware::access_log::tests::propagates_inner_status_when_handler_returns_non_2xx ... ok
test platform::middleware::access_log::tests::emits_one_log_per_request_with_status_and_latency ... ok
test platform::config::tests::http_addr_and_grpc_addr_format ... ok
test platform::middleware::recover::tests::non_panicking_handler_passes_through ... ok
test platform::config::tests::validate_cross_rejects_otlp_without_endpoint ... ok
test platform::config::tests::validate_cross_rejects_min_gt_max_conns ... ok
test platform::config::tests::load_from_yaml_string_succeeds ... ok
test platform::config::tests::validate_rejects_bad_port ... ok
test platform::middleware::request_id::tests::accepts_valid_charset ... ok
test platform::middleware::recover::tests::panicking_handler_yields_500_internal_body ... ok
test platform::middleware::request_id::tests::generates_when_missing ... ok
test platform::middleware::request_id::tests::extension_is_available_to_handlers ... ok
test platform::middleware::request_id::tests::validator_unit_table ... ok
test platform::server::grpc_interceptor::tests::extract_status_handles_missing_header_as_none ... ok
test platform::middleware::cors::tests::preflight_with_default_config_is_allowed ... ok
test platform::middleware::request_id::tests::rejects_slash_and_regenerates ... ok
test platform::middleware::request_id::tests::rejects_overlong_and_regenerates ... ok
test platform::server::grpc_interceptor::tests::non_zero_status_passes_through_unchanged ... ok
test platform::server::http::tests::parse_body_limit_bytes_known_units ... ok
test platform::server::http::tests::parse_body_limit_bytes_lowercase ... ok
test platform::middleware::request_id::tests::rejects_invalid_charset_and_regenerates ... ok
test platform::server::http::tests::parse_body_limit_bytes_negative_returns_zero ... ok
test platform::server::grpc_interceptor::tests::panic_is_recovered_and_returned_as_internal_status ... ok
test platform::server::grpc_interceptor::tests::success_passes_through_with_status_zero ... ok
test platform::server::shutdown::tests::with_shutdown_deadline_returns_graceful_result_on_success ... ok
test platform::server::shutdown::tests::with_shutdown_deadline_runs_fallback_on_timeout ... ok
test platform::telemetry::tests::metrics_body_on_empty_registry_returns_string_and_does_not_panic ... ok
test platform::middleware::cors::tests::preflight_with_explicit_methods_and_headers ... ok
test platform::valkey::tests::check_pings_real_valkey ... ignored, requires a live Valkey at valkey.local:6379
test platform::telemetry::tests::resolve_traces_endpoint_appends_v1_traces ... ok
test platform::middleware::cors::tests::preflight_with_explicit_origin_is_allowed ... ok
test platform::telemetry::tests::init_rejects_otlp_with_malformed_endpoint ... ok
test platform::valkey::tests::url_with_password ... ok
test platform::telemetry::tests::init_rejects_otlp_with_empty_endpoint ... ok
test platform::valkey::tests::url_with_special_password ... ok
test platform::telemetry::tests::init_with_none_returns_no_tracer_and_usable_registry ... ok
test platform::valkey::tests::url_without_password ... ok
test platform::telemetry::tests::shutdown_is_safe_with_no_tracer ... ok
test platform::telemetry::tests::init_can_be_called_repeatedly ... ok
test platform::db::tests::checker_name_is_postgres ... ok
test platform::telemetry::tests::init_with_otlp_returns_tracer_provider ... ok

test result: ok. 89 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 0.04s

running 8 tests
test tests::parse_command_defaults_to_up ... ok
test tests::parse_command_down_default_count_is_one ... ok
test tests::parse_command_force_rejects_non_integer ... ok
test tests::parse_command_force_requires_version ... ok
test tests::parse_command_rejects_unknown ... ok
test tests::parse_command_version ... ok
test tests::parse_command_down_with_count ... ok
test tests::parse_command_down_rejects_non_positive ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 5 tests
test scanner_tests::expands_plain_paths ... ok
test scanner_tests::expands_braced_groups_with_rename ... ok
test scanner_tests::ignores_comments_and_relative_imports ... ok
test scanner_tests::module_paths_collapse_mod_rs ... ok
test architecture_rules_hold ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

running 1 test
{"timestamp":"2026-08-30T05:43:55.163026Z","level":"INFO","fields":{"message":"","name":"MeterProvider.GlobalSet","message":"Global meter provider is set. Meters can now be created using global::meter() or global::meter_with_scope()."},"target":"opentelemetry"}
{"timestamp":"2026-08-30T05:43:55.163083Z","level":"INFO","fields":{"message":"starting server","version":"dev","commit":"unknown","build_time":"unknown","env":"development"},"target":"zercle_rust_template::app"}
{"timestamp":"2026-08-30T05:43:55.243484Z","level":"INFO","fields":{"message":"http listening","addr":"127.0.0.1:60438"},"target":"zercle_rust_template::platform::server"}
{"timestamp":"2026-08-30T05:43:55.243510Z","level":"INFO","fields":{"message":"grpc listening","addr":"127.0.0.1:60439"},"target":"zercle_rust_template::platform::server"}
{"timestamp":"2026-08-30T05:43:55.334294Z","level":"INFO","fields":{"message":"http request","request_id":"01a05132-22c6-7161-8642-3540e8fc0da5","method":"GET","path":"/healthz","status":200,"latency_us":28},"target":"zercle_rust_template::platform::middleware::access_log"}
{"timestamp":"2026-08-30T05:43:55.336736Z","level":"INFO","fields":{"message":"http request","request_id":"01a05132-22c6-7161-8642-35567e057c3b","method":"GET","path":"/readyz","status":200,"latency_us":2110},"target":"zercle_rust_template::platform::middleware::access_log"}
{"timestamp":"2026-08-30T05:43:55.337207Z","level":"INFO","fields":{"message":"http request","request_id":"01a05132-22c9-7b11-9cef-6ab428d26ef5","method":"GET","path":"/metrics","status":200,"latency_us":89},"target":"zercle_rust_template::platform::middleware::access_log"}
{"timestamp":"2026-08-30T05:43:55.341329Z","level":"INFO","fields":{"message":"http request","request_id":"01a05132-22c9-7b11-9cef-6ac776d99c57","method":"POST","path":"/api/v1/items","status":201,"latency_us":3816},"target":"zercle_rust_template::platform::middleware::access_log"}
{"timestamp":"2026-08-30T05:43:55.344177Z","level":"INFO","fields":{"message":"http request","request_id":"01a05132-22cd-7dc0-b20a-b509572dfff7","method":"GET","path":"/api/v1/items","status":200,"latency_us":2440},"target":"zercle_rust_template::platform::middleware::access_log"}
test server_end_to_end ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s

running 1 test
test example_http_round_trip ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s → 104 passed / 0 failed (89 lib + 8 migrate + 5 architecture + 1 e2e + 1 HTTP integration, live postgres+valkey)

    • validated (YAML parse)

BREAKING CHANGE: module paths changed (// folded into ; → ; feature modules re-layered).
EOF
)

…ontract

Mirrors zercle-go-template: each feature carries contract/domain/port/
application/adapter/{driving,driven}/di layers; platform/ replaces shared/ +
infrastructure/ + middleware/ + config.rs; a published api::v1 facade exposes
the inbound contract types + error codes outward-only.

- tests/architecture.rs: executable dependency gates (domain innermost,
  contract leaf, port->own domain, application->domain+port+contract,
  driving/driven adapter isolation, platform feature-agnostic, api
  facade outward-only); dedicated CI Architecture job
- platform/server decoupled from features: AppState + grpc_server() builder
  live in the shell; features hand over pre-mounted axum routers and a
  tonic Router via di::register
- application::Service speaks contract types at the boundary; wire-id
  parsing unified in the usecase (one validation path for HTTP + gRPC)
- sentinel->AppError mapping registered in feature di (RegisterSentinel
  parity), keeping the domain layer dependency-free
- ci: integration job now runs the FULL suite against postgres+valkey
  service containers (--include-ignored); all actions pinned to commit
  SHAs; persist-credentials: false on every checkout
- docs: README architecture/CI/testing sections rewritten for the new
  layout; Taskfile gains test-architecture, fixes stale --ignored
  invocations and a pre-existing YAML plain-scalar bug in the proto task

Verified: fmt=0; clippy --locked -D warnings=0; cargo test 104 passed /
0 failed (incl. live postgres+valkey e2e + HTTP integration);
architecture mutation probe red->green.

BREAKING CHANGE: module paths changed (shared/infrastructure/middleware
folded into platform/; feature modules re-layered; config moved to
platform::config).
@bouroo
bouroo merged commit e75d4d7 into main Aug 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant