Lab — Build a Governed API Operation
Learning objective: Extend a realistic ASP.NET Core API operation from ordinary authorization into an explicit governed-execution flow while preserving host-owned side effects and testable execution boundaries.
Difficulty: Intermediate
Pattern classification: Canonical pattern
Prerequisites: Complete Decision Before Execution, Policy Context and Explicit Decision Outcomes, Acknowledgment and Audit Residue, and Scoped Capability and Host-Owned Execution. Read When ASP.NET Core Authorization Is Enough before starting so that the authorization/governance boundary is explicit.
This lab bridges the foundational governance sequence into an ASP.NET Core API operation.
You will work with a fictional endpoint:
POST /accounts/{accountId}/disable
The endpoint represents a consequential operation because it changes account access.
The lab begins with an application that already authenticates callers and uses ASP.NET Core authorization.
That existing authorization boundary is not a defect.
The exercise asks a narrower question:
What additional architecture becomes useful when an already-authorized request can still be denied, deferred, paused for acknowledgment, escalated, or granted narrow execution authority?
The target flow is:
Request
↓
Authentication
↓
ASP.NET Core authorization
↓
Proposed intent
↓
Authoritative policy context
↓
Governance decision
↓
Acknowledgment when required
↓
Scoped authority
↓
Host-owned execution
↓
Audit residue
The primary invariant for the lab is:
Blocked decision
↓
Underlying account-service invocation count = 0
A second invariant is equally important:
Allowed decision
≠
Successful execution
An account service can fail after policy has correctly allowed the operation.
The decision and execution result must remain distinguishable.
Starting Architecture
Assume the application already has:
- Authentication configured by the host.
- An ASP.NET Core authorization policy named
CanDisableAccount. - An account repository.
- An account service that owns the real side effect.
A simplified endpoint may look like this:
app.MapPost(
"/accounts/{accountId}/disable",
async (
string accountId,
DisableAccountRequest request,
ClaimsPrincipal user,
IAuthorizationService authorization,
IAccountRepository accounts,
IAccountService accountService,
CancellationToken cancellationToken) =>
{
Account account =
await accounts.GetAsync(
accountId,
cancellationToken);
AuthorizationResult authorizationResult =
await authorization.AuthorizeAsync(
user,
account,
"CanDisableAccount");
if (!authorizationResult.Succeeded)
{
return Results.Forbid();
}
await accountService.DisableAsync(
account.Id,
request.Reason,
cancellationToken);
return Results.NoContent();
});
This flow is legitimate when the only application question is:
Is this actor authorized to disable this account now?
But suppose the application now has additional requirements:
Legal hold
→ Denied
Temporary maintenance hold
→ Deferred
Sensitive account
→ AcknowledgmentRequired
Protected account
→ EscalationRecommended
Normal account
→ Allowed
Those are not all ordinary authorization failures.
This is the boundary you will make explicit.
Part 1 — Identify the Real Execution Boundary
Locate the line that performs the side effect:
await accountService.DisableAsync(...);
Treat that call as the protected execution boundary for this lab.
Before changing the architecture, add or sketch a recording service:
public sealed class RecordingAccountService : IAccountService
{
public int DisableCallCount { get; private set; }
public Task DisableAsync(
string accountId,
string? reason,
CancellationToken cancellationToken)
{
DisableCallCount++;
return Task.CompletedTask;
}
}
The exact implementation can differ.
The important observation is measurable:
Did the underlying service execute?
Add a baseline test for an authorized normal account and confirm:
HTTP request succeeds
Account service calls = 1
Then add a baseline authorization-denied test and confirm:
ASP.NET Core authorization fails
Account service calls = 0
Record the distinction in your notes:
Authorization failure
↓
Request never enters governed operation
Later tests will add a different path:
Authorization succeeds
↓
Governance still blocks or pauses execution
That difference is the central learning objective.
Part 2 — Make the Exercise Disposable
Do not experiment directly in a production application.
Use one of these approaches:
- Create a small disposable ASP.NET Core application for the lab.
- Copy a representative endpoint and supporting abstractions into a temporary project.
- Use a feature branch or disposable test host in an existing training application.
A minimal disposable structure can be:
GovernedApiLab/
├── Program.cs
├── Governance/
│ ├── DisableAccountIntent.cs
│ ├── DisableAccountPolicyContext.cs
│ ├── GovernanceDecision.cs
│ ├── DisableAccountPolicy.cs
│ ├── AcknowledgmentChallenge.cs
│ ├── ExecutionCapability.cs
│ └── GovernanceResidue.cs
├── Accounts/
│ ├── Account.cs
│ ├── IAccountRepository.cs
│ └── IAccountService.cs
└── Tests/
└── DisableAccountEndpointTests.cs
You may keep everything in fewer files if that makes the exercise easier to inspect.
The objective is architectural visibility, not folder count.
If you use an ASP.NET Core integration-test host, configure test identity using your normal test-auth approach or test-host middleware.
Do not make the governance lesson depend on a particular authentication test package.
Part 3 — Keep Authorization as Authorization
Before adding governance, decide what CanDisableAccount should answer.
For this lab, keep authorization narrow:
Is the caller authenticated?
Is the caller permitted to request account-disable operations?
May the caller act on this account or tenant?
For example, the authorization layer may establish:
Actor has AccountOperator permission
Actor belongs to tenant-a
Account belongs to tenant-a
↓
Authorized
Do not encode every later workflow state into authorization failure reasons.
Avoid this style:
Authorization failed: maintenance hold
Authorization failed: acknowledgment required
Authorization failed: escalate to senior review
Those states mean different things operationally.
Keep this conceptual boundary:
ASP.NET Core authorization
↓
"May this actor enter this operation?"
Governance
↓
"What should happen next with this proposed operation?"
Add a test proving that authorization failure still prevents the governance path from reaching execution.
Then continue only with requests that have already passed authorization.
Part 4 — Represent Proposed Intent
Create an explicit intent model.
For example:
public sealed record DisableAccountIntent(
string AccountId,
string RequestedBy,
string? Reason,
string? AcknowledgmentId);
Construct it at the host boundary after authorization.
Do not let the request body choose authoritative actor identity.
For example:
string actorId =
user.FindFirst("sub")?.Value
?? throw new InvalidOperationException(
"Authenticated actor identifier is required.");
var intent = new DisableAccountIntent(
AccountId: account.Id,
RequestedBy: actorId,
Reason: request.Reason,
AcknowledgmentId: request.AcknowledgmentId);
The request may propose:
Reason
Acknowledgment reference
The host supplies:
Authenticated actor
Resolved account identity
Answer in your notes:
- Which intent fields came from the caller?
- Which fields came from host-owned identity or resource state?
- Could a caller substitute another actor or tenant by modifying JSON?
- Which values must never be trusted merely because a model binder produced them?
The intent describes the proposal.
It still performs no side effect.
Part 5 — Build Authoritative Policy Context
Create an explicit policy-context snapshot.
One possible model is:
public sealed record DisableAccountPolicyContext(
DisableAccountIntent Intent,
string ActorId,
string ActorTenantId,
string AccountId,
string AccountTenantId,
bool LegalHoldActive,
bool MaintenanceHoldActive,
bool RequiresSensitiveAcknowledgment,
bool IsProtectedAccount,
bool RequiredAcknowledgmentSatisfied,
string OperationName,
string CorrelationId,
string PolicyId,
string PolicyVersion);
Populate it from authoritative host sources where appropriate.
A useful ownership table is:
| Fact | Example source | Trust assumption |
|---|---|---|
| Actor identity | Authenticated principal | Established by host authentication |
| Actor tenant | Claims or directory lookup | Host-validated |
| Account identity | Route + repository resolution | Host resolves authoritative resource |
| Account tenant | Repository | Resource state, not caller assertion |
| Legal hold | Repository or policy-data source | Host-owned fact |
| Maintenance hold | Configuration/operational state | Host-owned fact |
| Sensitive flag | Resource classification | Host-owned fact |
| Protected flag | Resource metadata | Host-owned fact |
| Correlation ID | Host request context | Operational identity |
| Policy ID/version | Active policy descriptor | Decision provenance |
Do not turn the context object into a service locator.
Prefer:
Host gathers facts
↓
Context snapshot
↓
Policy evaluates snapshot
not:
Policy context contains repositories and HTTP services
↓
Facts are discovered unpredictably during evaluation
At this stage, add a focused unit test or inspection test confirming that the caller cannot override ActorTenantId, AccountTenantId, or policy identity through request input.
Part 6 — Evaluate an Explicit Governance Decision
Introduce the decision vocabulary used throughout Learning:
public enum GovernanceDecisionOutcome
{
Allowed,
Denied,
Deferred,
AcknowledgmentRequired,
EscalationRecommended
}
Use structured reasons:
public sealed record DecisionReason(
string Code,
string Message);
public sealed record GovernanceDecision(
GovernanceDecisionOutcome Outcome,
IReadOnlyList<DecisionReason> Reasons,
string CorrelationId,
string PolicyId,
string PolicyVersion)
{
public bool CanProceed =>
Outcome == GovernanceDecisionOutcome.Allowed;
}
Now implement a small policy.
For the lab, use behavior such as:
Legal hold
→ Denied
Maintenance hold
→ Deferred
Protected account
→ EscalationRecommended
Sensitive account without valid acknowledgment
→ AcknowledgmentRequired
Otherwise
→ Allowed
Example reason codes:
account.disable.legal-hold
account.disable.maintenance-hold
account.disable.protected-account
account.disable.acknowledgment-required
The policy should return data.
It should not:
- Call
IAccountService. - Return
IResult. - Return HTTP status codes.
- Write an HTTP response.
- Throw an exception to represent an expected denial.
Write one test per decision outcome before integrating the evaluator into the endpoint.
The policy tests should answer:
Given this context, what decision is produced?
They should not yet answer:
Was the account service called?
That second question belongs to the host integration tests later in the lab.
Part 7 — Map Governance Outcomes at the HTTP Host Boundary
Return to the endpoint.
After authorization and context construction:
GovernanceDecision decision =
policy.Evaluate(context);
The ASP.NET Core host now translates the decision into HTTP behavior.
A possible mapping is:
| Governance outcome | Example host behavior | Executes now? |
|---|---|---|
Allowed |
Continue toward capability + execution | Yes, if later validation succeeds |
Denied |
403 Problem Details or application-specific denial response |
No |
Deferred |
503 or application-specific retry/defer response |
No |
AcknowledgmentRequired |
409 with acknowledgment challenge information |
No |
EscalationRecommended |
409 or workflow-routing response |
No |
The exact status codes are host choices.
The policy evaluator should not know them.
For example:
static IResult MapBlockedDecision(
GovernanceDecision decision)
{
return decision.Outcome switch
{
GovernanceDecisionOutcome.Denied =>
Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Operation denied",
extensions: new Dictionary<string, object?>
{
["code"] = decision.Reasons[0].Code,
["correlationId"] = decision.CorrelationId
}),
GovernanceDecisionOutcome.Deferred =>
Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Operation deferred",
extensions: new Dictionary<string, object?>
{
["code"] = decision.Reasons[0].Code,
["correlationId"] = decision.CorrelationId
}),
GovernanceDecisionOutcome.AcknowledgmentRequired =>
Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Acknowledgment required",
extensions: new Dictionary<string, object?>
{
["code"] = decision.Reasons[0].Code,
["correlationId"] = decision.CorrelationId
}),
GovernanceDecisionOutcome.EscalationRecommended =>
Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Escalation recommended",
extensions: new Dictionary<string, object?>
{
["code"] = decision.Reasons[0].Code,
["correlationId"] = decision.CorrelationId
}),
_ => throw new InvalidOperationException(
"Only blocked outcomes should be mapped here.")
};
}
The guard exception at the end represents an impossible host-programming state in this sketch.
It is not being used to model expected governance denial.
If your application already follows the Centralized Error Handling and Problem Details convention, reuse its safe Problem Details shape.
This lab does not require that tutorial.
The requirement is simply:
Expected governance outcomes remain explicit values and are translated by the host.
Now add endpoint integration tests for:
Denied
Deferred
EscalationRecommended
Each must assert:
Account service calls = 0
Part 8 — Add an Acknowledgment Boundary
For a sensitive account, the first decision should be:
AcknowledgmentRequired
Do not solve this with:
{
"confirmed": true
}
A generic boolean does not say what was acknowledged or what operation it belongs to.
Create a small challenge model:
public sealed record AcknowledgmentChallenge(
string ChallengeId,
string ActorId,
string OperationName,
string ResourceId,
string ReasonCode,
string CorrelationId,
string PolicyId,
string PolicyVersion,
DateTimeOffset ExpiresUtc);
When policy returns AcknowledgmentRequired:
- Create a challenge.
- Store it in an in-memory challenge store for the exercise.
- Return the challenge identifier to the client.
- Do not call
IAccountService.
On the follow-up request:
- Load the challenge by identifier.
- Verify actor, operation, resource, policy identity, correlation, and expiration as appropriate.
- Reconstruct current policy context.
- Set only the specific acknowledgment fact that was actually satisfied.
- Re-evaluate policy.
The flow should become:
Authorized request
↓
Decision = AcknowledgmentRequired
↓
Challenge issued
↓
No execution
↓
Bound acknowledgment returned
↓
Current context reconstructed
↓
Policy re-evaluated
↓
Current decision
Add at least these tests:
AcknowledgmentRequired without acknowledgment
↓
Account service calls = 0
Acknowledgment for wrong resource
↓
Account service calls = 0
Expired acknowledgment challenge
↓
Account service calls = 0
Then create the successful acknowledgment path.
Do not let acknowledgment override unrelated rules.
For example:
Acknowledgment valid
+
Legal hold becomes active
↓
Decision = Denied
↓
Account service calls = 0
This proves that acknowledgment satisfies a specific boundary rather than becoming master permission.
Part 9 — Issue Narrow Execution Authority
After the current decision is Allowed, create a deliberately small execution capability.
For example:
public sealed record ExecutionCapability(
string CapabilityId,
string SubjectId,
string OperationName,
string ResourceId,
string Audience,
DateTimeOffset IssuedUtc,
DateTimeOffset ExpiresUtc,
string PolicyId,
string PolicyVersion,
string? AcknowledgmentId);
Issue it only after the allowed decision.
Bind it to:
Actor
Operation = account.disable
Resource = current account
Audience = account-service-gateway
Short expiration
Policy identity
Acknowledgment when required
Do not create:
Scope = account.*
Expires = end of session
Resource = any
Audience = any service
Validate the capability immediately before calling the account service.
The validation boundary should answer:
Is this authority valid for this actor,
this operation,
this resource,
this audience,
and this point in time?
Add a negative test by deliberately changing one binding after issuance.
Good choices include:
- Wrong resource.
- Wrong actor.
- Wrong audience.
- Expired capability.
- Stale policy version under a strict freshness rule.
The result must preserve:
Capability validation failure
↓
Account service calls = 0
Question the Capability Honestly
In a single request, single process, immediately executed endpoint, a separate capability object may be more machinery than the production problem needs.
That is an intentional discussion point.
The exercise uses a capability to make the approval-to-execution handoff visible.
After the lab, ask:
Would direct host-owned execution after the current allowed decision be simpler and equally safe for this application?
If the answer is yes, the smaller architecture may be preferable.
Capability artifacts become more compelling when approval and execution are separated by time, component, queue, trust boundary, or bounded-use requirements.
Part 10 — Preserve Host-Owned Execution
The governance evaluator still should not perform the side effect.
Keep the final transition in host code or a host-owned gateway:
Current decision = Allowed
↓
Scoped capability issued
↓
Capability validated
↓
Host-owned gateway
↓
IAccountService.DisableAsync(...)
One possible orchestration sketch is:
GovernanceDecision decision =
policy.Evaluate(context);
await auditSink.WriteDecisionAsync(
context,
decision,
cancellationToken);
if (!decision.CanProceed)
{
return MapBlockedDecision(decision);
}
ExecutionCapability capability =
capabilityIssuer.Issue(
context,
decision);
CapabilityValidationResult validation =
capabilityValidator.Validate(
capability,
context,
DateTimeOffset.UtcNow);
if (!validation.IsValid)
{
await auditSink.WriteExecutionBlockedAsync(
context,
validation.Code,
cancellationToken);
return Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Execution authority is no longer valid");
}
try
{
await accountService.DisableAsync(
context.AccountId,
context.Intent.Reason,
cancellationToken);
await auditSink.WriteExecutionCompletedAsync(
context,
cancellationToken);
return Results.NoContent();
}
catch
{
await auditSink.WriteExecutionFailedAsync(
context,
cancellationToken);
throw;
}
This is a teaching sketch.
The important separation is:
Policy decides
Host coordinates
Account service executes
The policy is not the account service.
The capability is not the account service.
The acknowledgment is not the account service.
Part 11 — Record Decision and Execution as Different Evidence
Create a small residue model or recording sink for the exercise.
You need to distinguish at least:
decision
acknowledgment
capability-validation
execution-completed
execution-failed
execution-blocked
A small event shape might include:
public sealed record GovernanceResidue(
string EventId,
string CorrelationId,
string ActorId,
string OperationName,
string ResourceId,
string Stage,
string Outcome,
IReadOnlyList<string> ReasonCodes,
string PolicyId,
string PolicyVersion,
DateTimeOffset OccurredUtc);
Do not rewrite an allowed decision as denied merely because execution later failed.
This timeline is valid:
Decision = Allowed
↓
Capability validation = Succeeded
↓
Execution attempted
↓
Account service throws
↓
Execution = Failed
The evidence should preserve both:
Policy allowed the operation.
and:
The side effect did not complete successfully.
Add a test in which the recording account service throws after incrementing its invocation count.
The test should confirm:
Decision residue outcome = Allowed
Account service calls = 1
Execution residue outcome = Failed
If your application has centralized exception handling, the HTTP result may become a safe 500 Problem Details response.
If it does not, the integration-test host may observe the exception directly.
This lab does not require one specific exception-transport mechanism.
The required invariant is the decision/execution distinction.
Part 12 — Add Integration Tests for the Architectural Invariants
Your final integration-test matrix should include at least these scenarios.
| Scenario | Authorization | Governance | Expected service calls |
|---|---|---|---|
| Caller not authorized | Failed | Not reached | 0 |
| Legal hold | Succeeded | Denied |
0 |
| Maintenance hold | Succeeded | Deferred |
0 |
| Sensitive account, no acknowledgment | Succeeded | AcknowledgmentRequired |
0 |
| Protected account | Succeeded | EscalationRecommended |
0 |
| Allowed + invalid capability | Succeeded | Allowed |
0 |
| Allowed + valid capability | Succeeded | Allowed |
1 |
| Allowed + service throws | Succeeded | Allowed |
1 |
A test can use a recording service such as:
Assert.Equal(0, accountService.DisableCallCount);
for every blocked path.
The most important tests are not assertions about returned enum values.
They cross the host boundary and prove whether the side-effect service was invoked.
Suggested Test Names
Use names that state the invariant directly.
For example:
AuthorizationDenied_DoesNotInvokeAccountService
GovernanceDenied_DoesNotInvokeAccountService
Deferred_DoesNotInvokeAccountService
AcknowledgmentRequiredWithoutAcknowledgment_DoesNotInvokeAccountService
InvalidCapability_DoesNotInvokeAccountService
AllowedWithValidCapability_InvokesAccountServiceOnce
ExecutionFailure_PreservesAllowedDecisionAndRecordsExecutionFailure
Avoid tests whose only assertion is:
decision.Outcome == Denied
That proves policy evaluation.
It does not prove that the host respected the decision.
Part 13 — Deliberately Break the Boundary
Choose one failure mode and introduce it temporarily.
Option A — Ignore the Governance Decision
Change the host so it always calls the account service after evaluation.
Your denied/deferred/acknowledgment tests should fail because the service call count becomes nonzero.
Option B — Treat Acknowledgment as Permission
Skip re-evaluation after a valid acknowledgment.
Then activate a legal hold between challenge issuance and continuation.
A good test should expose the stale decision.
Option C — Skip Capability Validation
Issue a capability for account A and execute account B.
The invalid-capability test should fail if resource binding is ignored.
Option D — Move the Side Effect into Policy
Call IAccountService from the evaluator when it reaches Allowed.
Then ask:
Can the host still inspect or map the decision before execution?
Can a test prove that blocked outcomes never executed?
Can another transport reuse the policy without inheriting side effects?
Restore the correct architecture after observing the failure.
The point of deliberately breaking the design is to make the boundary causal rather than ceremonial.
Part 14 — Compare the Final Design with Plain Authorization
Now compare your completed endpoint with the starting endpoint.
The starting design was approximately:
Request
↓
Authentication
↓
Authorization
↓
Account service
The governed version is approximately:
Request
↓
Authentication
↓
Authorization
↓
Intent
↓
Authoritative context
↓
Governance decision
├── Denied → host response
├── Deferred → host response
├── AcknowledgmentRequired → challenge / pause
├── EscalationRecommended → alternate workflow
└── Allowed
↓
Scoped authority
↓
Execution-boundary validation
↓
Host-owned account service
↓
Execution residue
Answer these questions:
- Which requirements genuinely required more than authorization?
- Which requirements would still be clearer as ASP.NET Core authorization rules?
- Which governance outcomes represent workflow states rather than access-control failures?
- Did any caller-controlled field accidentally become authoritative policy context?
- Can every blocked path prove
Account service calls = 0? - Can an allowed decision be distinguished from execution success?
- Is acknowledgment bound to the operation, actor, resource, and current policy path?
- Is execution authority narrower than the user's broad session identity?
- Is capability machinery justified in your real deployment topology?
- Could a simpler architecture preserve the same invariants?
The canonical pattern is not a requirement to use every layer everywhere.
The architectural standard is:
Use the smallest design that preserves the boundaries the operation actually needs.
Final Validation
Before completing the lab, verify all of the following.
- Authentication remains a host concern.
- ASP.NET Core authorization still answers an access-control question.
- Authorization failure prevents the governed operation from executing.
- Proposed intent is explicit and performs no side effect.
- Actor, tenant, resource, correlation, and policy identity come from authoritative host sources where appropriate.
- Policy context is a decision-time snapshot rather than a service locator.
- Governance produces explicit
Allowed,Denied,Deferred,AcknowledgmentRequired, andEscalationRecommendedoutcomes. - Expected governance outcomes are not represented by throwing exceptions.
- HTTP status codes are chosen by the ASP.NET Core host, not the policy evaluator.
- Acknowledgment is bound and does not become an authorization override.
- Current context is re-evaluated after acknowledgment when the exercise requires it.
- Scoped authority is issued only after the current decision permits execution.
- Scoped authority is validated immediately before the host-owned side effect.
- Every blocked path leaves
IAccountService.DisableAsyncuncalled. - An allowed path with valid execution authority calls the service exactly once.
- A service failure after approval leaves the original decision as
Allowedand records execution failure separately. - Decision evidence and operational execution evidence remain distinguishable.
The final invariant should be mechanically testable:
Blocked or stale path
↓
Account service calls = 0
and the successful path should be equally explicit:
Authorized
+
Current governance decision = Allowed
+
Valid scoped authority
↓
Account service calls = 1
Completion Criteria
You have completed the lab when you can explain why each statement answers a different question:
The caller is authenticated.
The caller is authorized to request account disablement.
The proposed operation is currently allowed by governance policy.
Any required acknowledgment has been satisfied.
The execution authority is valid for this exact operation and resource.
The host invoked the account service.
The account service completed successfully.
You should also be able to explain why this statement is insufficient:
The request returned success, therefore every architectural boundary was correct.
A useful architecture makes the intermediate boundaries observable and testable rather than inferring them from the final HTTP status alone.
Related Content
- Decision Before Execution — revisit the boundary between proposed intent, governance decision, and side effect.
- Policy Context and Explicit Decision Outcomes — review authoritative decision-time facts, explicit outcomes, and reason codes.
- Acknowledgment and Audit Residue — review bound acknowledgment, re-evaluation, correlation, and separate decision/execution evidence.
- Scoped Capability and Host-Owned Execution — review narrow authority and execution-boundary validation.
- When ASP.NET Core Authorization Is Enough — compare the governed flow with the simpler authorization-only architecture and the hybrid pattern.
- Centralized Error Handling and Problem Details — optionally reuse the repository's safe host-side response convention for expected governance mapping and unexpected failures.
- ASP.NET Core learning area — connect the exercise to middleware, configuration, logging, and error-handling architecture.
- AsiBackbone/AsiBackbone — inspect fuller governance decision, audit-residue, capability, and host-integration concepts after completing the teaching exercise.
- AsiBackbone/NetCoreApplicationTemplate — compare the disposable lab with a broader ASP.NET Core reference architecture.
Read it. Run it. Question it. Improve it.