feat!: clean architecture (DDD) restructure with exposed inbound contract + hardened CI - #13
Merged
Merged
Conversation
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 facadecrate::api::v1re-exports these types + error codes for external consumers.features/example/domain/— entities + sentinel errors (zero crate deps)features/example/port/— outboundRepositoryportfeatures/example/application/— inboundServiceport that speaks contract types at the boundary +Usecaseimpl (owns domain↔contract mapping; single uuid-validation path shared by HTTP + gRPC)features/example/adapter/driving/{http,grpc}+adapter/driven/postgres— interface adaptersfeatures/example/di.rs— wiring + sentinel→AppErrorregistration (RegisterSentinelparity)Platform & composition
platform/replacesshared/+infrastructure/+middleware/+config.rs; feature-agnostic by ruleplatform::serverdecoupled from features:AppState+grpc_server()builder live in the shell; features hand over pre-mounted routersapp.rscomposition root wires telemetry → db → valkey → health →features::example::di::registerExecutable architecture gates
tests/architecture.rsscans everyuse crate::…statement and enforces 8 layering rules (mirrors Go'sarchitecture_test.go; dedicated Architecture CI jobCI (GitHub Actions)
Verification
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)
BREAKING CHANGE: module paths changed (// folded into ; → ; feature modules re-layered).
EOF
)