AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Domain-Centric Architecture reference implementation in .NET: ASP.NET Core MVC shop with bounded contexts as projects, governed by DomainCentric.ArchRules.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
The .NET reference implementation of Domain-Centric Architecture (DCA) — a synthesis of Domain-Driven
Design, Hexagonal Architecture and Clean Architecture. A small webshop built with ASP.NET Core, consuming
DomainCentric.BuildingBlocks (the markers) and DomainCentric.ArchRules (the executable rules).
It ships seven bounded contexts — Product Catalog, Shopping Cart, Checkout, Pricing,
Inventory, Account, Portal — with the same ubiquitous language and use cases as the Java twin
(dca-ecommerce-sample-java), plus the Backoffice context, the REST API and an MCP server.README 전문 보기 ↗The .NET reference implementation of Domain-Centric Architecture (DCA) — a synthesis of Domain-Driven
Design, Hexagonal Architecture and Clean Architecture. A small webshop built with ASP.NET Core, consuming
DomainCentric.BuildingBlocks (the markers) and DomainCentric.ArchRules (the executable rules).
It ships seven bounded contexts — Product Catalog, Shopping Cart, Checkout, Pricing,
Inventory, Account, Portal — with the same ubiquitous language and use cases as the Java twin
(dca-ecommerce-sample-java), plus the Backoffice context, the REST API and an MCP server.
Written with AI assistance — drafted mainly by Claude, reviewed and directed by Christoph Bloemer since 2025. The architecture rules in this repository's build are part of how that work is verified.
Requires the .NET 10 SDK (pinned by global.json).
dotnet build
dotnet test # unit, integration, architecture (DCA rule catalog)
dotnet run --project src/DcaShop.Web # http://localhost:5080
Browse /products, add items to the cart, check out in five steps (buyer → delivery → payment → review →
confirmation), register an account and log in. All state is in memory; restarting resets the shop, accounts
included.
Payment goes to a stand-in inside the sample. To pay through a payment service provider over REST, give its address
as Checkout:PaymentProvider:BaseUrl (environment: Checkout__PaymentProvider__BaseUrl); RestPaymentProvider
then sends POST /payments and treats no answer within Checkout:PaymentProvider:Timeout (default 2 seconds) as
the provider being unavailable.
To see the payment provider on this machine, run the shop against its stub — WireMock on localhost:8089, which
answers authorize (default), refuse or slow (stubs/payment-provider/README.md). The justfile wraps it
(brew install just; just lists the recipes):
just run-with-provider refuse # the stub, then the shop paying through it; the stub stops with the shop
just run-with-provider authorize 5091 # another answer, another port
just stub slow # only the stub — another answer while the shop keeps running
Without just: PAYMENT_STUB=refuse docker compose --profile provider-stub up -d payment-provider, then
Checkout__PaymentProvider__BaseUrl=http://localhost:8089 dotnet run --project src/DcaShop.Web.
The shop runs as several instances behind one load balancer, without sticky sessions, once they share a key ring:
the cookies it seals — the notice a redirect leaves for the next page (TempData), the antiforgery token, the
backoffice session — open on every instance that holds the same keys. Point DataProtection:KeysDirectory
(environment: DataProtection__KeysDirectory) at a directory every instance reads, a mounted volume for instance;
DataProtection:ApplicationName (default dca-shop) must be the same on all of them. Left empty, as in development,
the keys go to dca-shop-dotnet/data-protection-keys in the system's temp directory.
Architecture tests must run on Debug builds — ArchUnitNET drops the async state machines of optimized builds and would miss dependencies inside
asyncmethod bodies.dotnet testdefaults to Debug.
The packages come from NuGet.org (DomainCentric.BuildingBlocks, DomainCentric.ArchRules.Xunit). For work on
unreleased rules, -p:UseLocalDcaDotnet=true references the sibling checkout ../dca-dotnet as projects instead
(see Directory.Build.props).
No local SDK needed:
docker compose up --build # http://localhost:5080
docker compose run --rm test # unit, integration, architecture tests (Debug build)
# (podman-compose: podman-compose --profile tools run --rm test)
docker compose run --rm dotnet build # any other dotnet command, packages cached in a volume
Without compose: docker build -t dca-shop-dotnet ., then docker run --rm -p 5080:8080 dca-shop-dotnet.
The tools service mounts the parent directory, so docker compose run --rm dotnet test -p:UseLocalDcaDotnet=true
works against the sibling ../dca-dotnet checkout as well.
tests/DcaShop.E2eTests drives the shop through a real browser (Playwright, page objects, data-test
selectors). The suite starts the shop itself, once per run, in the test process and on a free port — nothing has to
run beforehand:
dotnet test tests/DcaShop.E2eTests
E2E_BASE_URL=http://localhost:5080 dotnet test tests/DcaShop.E2eTests # against a shop started elsewhere
E2E_BROWSER (chromium | firefox | webkit) and E2E_HEADLESS=false are honoured. Because the markup is the
same as the Java sample's, the suite passes against either shop — the browser tests are the language-neutral
acceptance test of the architecture.
Without a local browser install, the compose service brings its own — the image ships the browser builds of the Playwright release the suite references, so the run is the same everywhere:
docker compose --profile tools run --rm e2e # podman-compose needs the --profile flag as shown
EmbeddedShopE2eTest covers the shop inside someone else's iframe — a slide deck, a docs page, a demo. It starts a
throwaway page on another port of this host, frames the shop in it and adds a product to the cart. That case needs
no configuration: the shop allows framing out of the box, and another port is the same site, so its Lax cookies
travel into the frame unchanged.
Framing from another site is the second case, and only that one needs the cookie policy relaxed.
EmbeddingPolicyE2eTest starts the shops for it on ports of their own: one out of the box, which a foreign site frames
but cannot use, and one with Jwt__SameSite=None Jwt__SecureCookies=true over TLS — a browser demands Secure for a
SameSite=None cookie — which takes the product into its cart inside the foreign frame.
One assembly per bounded context, one for the shared kernel, one for global infrastructure, one web host:
src/
├── DcaShop.SharedKernel/ Money, Price, ProductId, UserId; transaction boundary, event dispatch, event outbox (two listener kinds)
├── DcaShop.Product/ Product Catalog (namespace DcaShop.Product)
├── DcaShop.Cart/ Shopping Cart (namespace DcaShop.Cart)
├── DcaShop.Checkout/ Checkout (namespace DcaShop.Checkout)
├── DcaShop.Pricing/ Pricing (namespace DcaShop.Pricing)
├── DcaShop.Inventory/ Inventory (namespace DcaShop.Inventory)
├── DcaShop.Account/ Account (namespace DcaShop.Account) — accounts, credentials, sessions
├── DcaShop.Portal/ Portal (namespace DcaShop.Portal) — the landing page; a UI shell with no domain model
├── DcaShop.Backoffice/ bounded context, generic subdomain — the event publication log
├── DcaShop.Infrastructure/ composition root, outbox dispatcher (retry/backoff), sample data
└── DcaShop.Web/ ASP.NET Core host: Razor views, layout + mini basket, home/error pages, wwwroot (controllers live in the contexts)
tests/
├── DcaShop.UnitTests/ aggregate and value-object tests
├── DcaShop.IntegrationTests/ browse → cart → checkout through the HTTP pipeline (WebApplicationFactory)
├── DcaShop.ArchitectureTests/ the DCA rule catalog + the executable context map
└── DcaShop.E2eTests/ Playwright browser tests with page objects (run against a started shop)
The project boundaries organise the code; they do not isolate it. Every context project references
Microsoft.AspNetCore.App, because its controllers live inside the context, and the context projects it
integrates with (DcaShop.Cart references Product, Pricing and Inventory). The compiler would therefore accept a
domain class that uses an MVC type or a sibling's repository. What stops it is the rule catalog in
tests/DcaShop.ArchitectureTests: DCA-ONI-002 keeps the domain on System, the building blocks and the
layout's allow-list, and DCA-STR-006 lets an outgoing adapter reach a sibling only through its Api/ and
Events/ namespaces. The isolation is logical, enforced by the rules. The eight contexts are deliberately not
split into Domain, Application and Adapters projects: that would triple the project count for a boundary the
rules already hold.
Inside a context project the folders are the DCA layers:
DcaShop.Cart/
├── CartContext.cs [BoundedContext], [Upstream], [Partnership] — the context declaration
├── Domain/
│ ├── Model/ ShoppingCart (aggregate), CartItem (entity), Quantity, CartArticle, EnrichedCart …
│ ├── Event/ CartItemAddedToCart, CartCompleted … (past tense)
│ ├── Specification/ ActiveCart, HasMinTotal, LastUpdatedBefore … + ICartSpecificationVisitor
│ └── glossary.md the context's ubiquitous language
├── Application/ use cases grouped by feature (Cart and Checkout); the other contexts stay flat
│ ├── Shopping/ feature — filling and reading the cart
│ │ ├── AddItemToCart/ I*InputPort : IUseCase<Command, Result>, *UseCase, *Command, *Result
│ │ └── GetCartById/ … one folder per use case
│ ├── CartRecovery/ feature — RecoverCart, GetCartMergeOptions, MergeCarts
│ ├── CartCheckout/ feature — CompleteCart
│ ├── CartOversight/ feature — GetAllCarts (staff-only)
│ └── Shared/ output ports, context-wide: IShoppingCartRepository, IArticleDataPort
├── Adapter/
│ ├── Incoming/Web/ protocol first, feature below: Shopping/CartPageController, CartRecovery/CartMergePageController
│ ├── Incoming/Event/ CartCheckout/CartCompletionEventConsumer
│ └── Outgoing/ Persistence/ (in-memory repository), Product/ (ACL to Product/Pricing/Inventory), Event/
├── Api/ CartService — Open Host Service for other contexts
├── Events/ CartContentsChangedEvent, ICartCompletionTrigger — published language
└── Infrastructure/ CartContextRegistration.AddCartContext() — explicit DI wiring
The pages are a one-to-one translation of the Java sample's Pug templates into Razor: same stylesheet
(wwwroot/css/main.css), same product images, same CSS classes and data-test attributes, same routes
(/products, /products/{id}, /cart, /cart/add-product, POST /checkout/start, /checkout/buyer →
delivery → payment → review → confirm → confirmation). The active checkout session is resolved from the
customer, not from the URL. The account pages follow the same rule (/login, /register, POST /logout,
/account, /account/profile, /account/change-password, /cart/merge), and so does the backoffice
(/backoffice/login, /backoffice/events). Two differences in the HTML are intended: the antiforgery hidden
field in every form, and the corner ribbon that names the running implementation
(.stack-ribbon--dotnet here, .stack-ribbon--java there) — with both shops open in two tabs, the ribbon is
what tells them apart.
| Pattern | Where |
|---|---|
| Aggregate with invariants and domain events | ShoppingCart, CheckoutSession, Product |
| Entity created only through its root | CartItem (internal constructor) |
Value objects as records / readonly record struct ids | Money, Quantity, CartId, BuyerInfo |
| Factory | ProductFactory, EnrichedCartFactory, CheckoutCartFactory |
| Facts passed into the aggregate | ICheckoutArticlePriceResolver — the application fetches, the model decides |
| Enriched read model | EnrichedProduct, EnrichedCart, CheckoutCart / EnrichedCheckoutLineItem (persisted line item + fresh article data) |
| Use case = input port + command/query + result | every Application/<UseCase>/ folder — or Application/<Feature>/<UseCase>/ where a context groups them (Cart: Shopping, CartRecovery, CartCheckout, CartOversight; Checkout: Session, CheckoutCompletion, CartSync) |
Output ports in Application/Shared, adapters outside | IArticleDataPort ↔ CompositeArticleDataAdapter |
| One port answered from several Open Host Services | CompositeArticleDataAdapter — product identity from the catalog, price from Pricing, availability from Inventory |
| Anti-corruption layer to another context's Api | Adapter/Outgoing/Product/, Adapter/Outgoing/Cart/ |
| Open Host Service | ProductCatalogService, CartService |
| Domain event → integration event relay | CheckoutConfirmedEventPublisher → CheckoutConfirmedEvent |
| Interface inversion between contexts | CheckoutConfirmedEvent : ICartCompletionTrigger (owned by Cart), ProductCreatedEvent : IPriceInitializationTrigger, IStockInitializationTrigger |
Three surfaces sit beside the shop pages.
REST (/api/**) is authenticated by an Authorization: Bearer token and by nothing else — on those paths
the Bearer authentication scheme neither reads a cookie nor writes one, which is the only reason they may skip the
antiforgery token (ADR-007). The identity scheme enriches rather than gates: a request without a token is anonymous,
and anonymous is still an identity — but not an authenticated one, so a staff route answers 401 to a stranger and
403 to a customer without the role, through [Authorize(Roles = …)] on the resource (ADR-008).
| Route | Who |
|---|---|
GET /api/products, GET /api/products/{id} | anyone — the same assortment the shop pages show |
POST /api/products | staff role |
PUT /api/products/{id}/price (Pricing's resource) | staff role |
GET /api/carts (every cart in the shop) | staff role |
POST /api/carts, GET /api/carts/{id}, POST /{id}/items, DELETE /{id}/items/{productId}, POST /{id}/checkout | the caller, on their own cart — a stranger's cart answers 404, never 403 |
Ownership is enforced by the use cases, not by the resource: the caller is part of the command and the
repository is asked a scoped question, so the web pages and any future adapter inherit the same rule. The staff
gate is the one check that stays at the edge — it reads only the token's claims, and the same use case is
legitimate for a console with no HTTP identity at all.
| POST /api/auth/{login,register,logout} | anyone; the token comes back in the body, no cookie is set |
Every refused request answers with an RFC 9457 problem document (application/problem+json), built by the
context's own *ApiExceptionHandler. The status follows what went wrong, not which layer noticed it:
| Status | When |
|---|---|
404 | the addressed thing is not there, or not the caller's — a cart, a session, an article, a position in a cart |
409 | a conflict with existing state — a stock keeping unit already in use, a second active cart, a cart that no longer takes changes |
422 | the request was understood and a rule refused it — a password below the strength rules, a checkout step that has no data yet |
400 | a value the model does not accept at all — a malformed stock keeping unit, a blank name |
{ "type": "about:blank", "title": "Stock keeping unit already in use", "status": 409,
"detail": "A product with SKU API-42 already exists." }
The failures themselves are types, not status codes: the use cases and the model raise DuplicateSkuException,
CartNotFoundException, InsufficientStockException and their kin — subtypes of DomainException and
UseCaseException from DomainCentric.BuildingBlocks — and only the adapter decides what a caller is told
(ADR-016). An argument guard stays ArgumentException: "must not be null" is a caller contract, not a business
rule. ApiProblemDetailTest holds that mapping; the pages keep the HTML error endpoint and map the same two base
types into form errors.
MCP (/mcp, ModelContextProtocol.AspNetCore) exposes the catalog as the two tools all-products and
product-by-id, over the same input ports and the same DTO converter as the REST resources — one representation
of a product, not two that can drift. Bearer-only, like /api/**. The server introduces itself as
dca-ecommerce-sample-mcp-server, version 1.0.0, as the Java sample's does. product-by-id takes any string as
its id. A blank id is a tool error ("Product ID cannot be null or blank"), and an id that names no product answers
null.
Backoffice (/backoffice/events, reachable from the footer) is a bounded context of a generic subdomain —
operating this application. It owns a language no business context uses (an event publication is a dispatched
domain event with a completion status), so it carries [BoundedContext] and appears on the context map; it reads
what others have published and negotiates no contract, hence Separate Ways. In transaction-script style it has no
domain model of its own. It signs operators in under its own cookie scheme with its own credentials
(admin/admin by default, Backoffice section in appsettings.json) — a staff session and a shopper session
must never be the same cookie. Those credentials are committed, so the shop refuses to start on them outside
ASPNETCORE_ENVIRONMENT=Development (ADR-015), as it does for the committed signing secret and for cookies that
are not marked Secure.
What the event log actually shows. The Java sample reads Spring Modulith's
EVENT_PUBLICATIONtable: one row per domain event per listener, completed when that listener returned. This shop keeps an event outbox instead: one row per integration event, and per domain event a transactional listener waits for, with each listener's status, attempt count and last error. A domain event that only synchronous listeners hear leaves no record. The page lists one entry per event and listener, as Java does — each with its own badge, completion date and, when it failed, its own "Retry failed listener" button (data-test="event-retry"). It shows the same three numbers (total / completed / incomplete, counted per entry) and the samedata-testhooks, so the Java browser suite passes against it, but the underlying record is not the same one: integration events here, domain events there.
See docs/architecture/context-map.md (generated from the context
attributes) and product/domain.md (the designed, hand-maintained strategic reading). Cart and Checkout each consume the Product
Catalog through an ACL; Checkout consumes the Cart's Api (ACL), conforms to its ICartCompletionTrigger
contract; Cart's CartContentsChangedEvent leaves the captured session unchanged; the Product Catalog reads price and
stock from the Pricing and Inventory Open Host Services, which in turn fill themselves from
ProductCreatedEvent; confirming a checkout reduces stock. Cart and Checkout, and Checkout and Inventory, are
partners over the trigger contracts they share.
What the shop is and how it is built is decided in product/: the product
(overview.md — surfaces, how it works, look and feel with the page sizes s, m, l
and xl), the technical decisions (tech.md), the designed domain
(domain.md) and the backlog under product/epics/. Stories are delivered by a
gated pipeline under .agents/factory/ — plan, test, build, tidy, judge, document — and a story with
a page waits for a human's acceptance before it counts as delivered.
This is an architecture sample, not a production template. Three pieces are intentionally minimal:
InMemoryTransactionBoundary draws the boundary and runs after-commit/after-rollback hooks,
but repositories have nothing to roll back; see ADR-004.Failed state,
but durable only within the process — a restart loses outstanding publications; see ADR-002 for the
database-backed variant.Role.Staff guards the operator routes of the API, but no
registration grants it — the tests mint such a token out of band. Authorization also lives in each resource
rather than in a policy the pipeline enforces; see ADR-007.All three live entirely in adapters — the domain and application layers are unaffected when they are replaced.
Architecture decision records live in docs/architecture/adr/.
Christoph Bloemer — @chbloemer
MIT — see LICENSE.
Contributions are accepted under the MIT licence, and the copyright holder may additionally publish them under other licences (for example a documentation licence for prose).
Event delivery (2026-09-09): committed capture is recovered independently of wakeup; completion is per listener/consumer. Automatic retry is bounded (five attempts, 200 ms exponential base), terminal failures remain inspectable, and Backoffice offers authenticated, CSRF-protected manual replay of failed work. Original payload/identity and acknowledged consumers are preserved. Provider idempotency is needed to suppress an external duplicate after acceptance-before-ack; local keys alone do not suffice. See the event-delivery ADR for storage limits and replay behavior.
Money uses ISO 4217 codes, non-negative amounts, two decimals half-up, and an upper bound of 999999999999.99. Product creation and Pricing use strictly positive Price. Product-created v1 is the six-field notification described in the local payload ADR. Invalid default quantities are rejected at entry/reconstitution; account role observations are immutable snapshots. Aggregate creation owns event registration.
An explicit checkout action captures immutable positions and quantities, and the prices of that moment, into a session. Cart edits do not create or mutate sessions. A new action supersedes the previous OPEN/Active session; confirmed/completed orders remain. Confirmation and replacement serialize through the same repository operation, including transaction completion. Superseded confirmation has no completion effect. Abandonment/expiry closes only an open session and leaves cart contents.
Cart reconciliation intersects purchased unit intervals with the current stable position id. Later additions (also of the same product), removed/re-added positions and other contents survive. Replay and overlapping completed snapshots cannot remove a unit twice. JDBC/JPA cart persistence preserves the interval allocation watermark; in-memory persistence retains the same domain state. The legacy Completed cart status remains readable, but snapshot checkout leaves an active cart editable and never completes the whole cart.
Confirmation retrieves current price/availability/stock facts before its local transaction. Pure domain services consume immutable line/fact snapshots. The review shows the order at today's prices; "Place Order" carries the order fingerprint the review showed. A shortage, or a fingerprint that differs at that moment, is refused and leaves state, totals and events unchanged; the review then shows today's order. Success stores the lines at today's prices with their total and publishes the same total; there is no confirmation without a fingerprint. Local in-memory repository serialization is not a claim of durable distributed transactions or universal rollback of unenlisted resources.
The language-neutral specification is an independently owned, currently unpublished repository. It is not part of the
build: a plain checkout builds and runs without it, and the specification tests are reported as skipped. To run them,
point the build at a local checkout; the vectors are copied next to the test assemblies and the adapters in
SharedSpecificationTest, CheckoutSpecificationTest and RetainedDeliveryTest drive the production code with them.
The end-user scenarios the browser suite implements are this shop's product/spec/**/scenarios.md, test for test under
the scenario title; SharedScenariosTest checks that binding against the sources of tests/DcaShop.E2eTests on every
build. The specification's own scenarios.md is the history of the behaviour both shops share and binds no suite.
dotnet test -c Debug -p:SpecificationPath=../dca-sample-specification
| Idempotent consumer, per effect | every trigger extends IIntegrationEvent; the consumer hands the EventId to the use case, which checks and records it in IProcessedEvents under its own effect (ReduceStockUseCase: reduce-stock) inside its transaction |
| Domain service | CheckoutPricing, CartPricing — take the aggregate, called by the use case |
| Read model detached from the aggregate | CheckoutCartSnapshot, LineItemSnapshot |
| Eventual consistency between contexts | explicit checkout → immutable snapshot; checkout confirmed → snapshot reconciliation and ReduceStock, which consumes the stock ConfirmCheckoutUseCase reserved through InventoryService before its transaction (released again when the confirmation fails) |
| Domain gateway called by the aggregate | IPasswordHasher — the contract in Account/Domain/Gateway, BCrypt in the adapter |
| Specification as a first-class rule | UsableDateOfBirth — evaluated by Owner and by the change-profile use case |
| Composable specifications translatable by an adapter | ActiveCart, HasMinTotal, HasAnyAvailableItem … over ICompositeSpecification<T>, visited by ICartSpecificationVisitor; AvailableQuantityBelow in Inventory, visited by IStockLevelSpecificationVisitor |
| Repository query in domain terms, paged | IShoppingCartRepository.FindByAsync(specification, PagingRequest) → PageResult<ShoppingCart> |
| Settlement checked against current figures | ShoppingCart.ValidateForCheckout(facts) → CartValidationResult; Checkout's StartCheckoutUseCase refuses a cart whose articles are gone or short in stock |
| Shared-kernel port with one context's implementation | IIdentityProvider (shared kernel) resolved by Account's authentication handler from HttpContext.User |
| Async at the ports, synchronous domain | Task<TOut> ExecuteAsync(...) vs. plain domain methods |
| Executable context map | docs/architecture/context-map.md, rendered by the architecture tests |
| One protocol per adapter, one set of use cases | ProductPageController (Razor), ProductResource (REST), ProductCatalogMcpToolProvider (MCP) |
| Operational module beside the contexts | DcaShop.Backoffice — no context marker, its own authentication |