-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path06-reconciliation.html
More file actions
353 lines (323 loc) · 19.4 KB
/
Copy path06-reconciliation.html
File metadata and controls
353 lines (323 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>06 · Reconciliation — Multi-Tenant Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" />
<link rel="stylesheet" href="../assets/styles.css" />
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="../index.html" class="brand">
<span class="brand-mark">MTP</span>
<span class="brand-text">Multi-Tenant Platform</span>
</a>
<nav>
<div class="nav-group">
<h4>Foundations</h4>
<ul>
<li><a href="../index.html"><span class="nav-num">00</span>Overview</a></li>
<li><a href="01-findings.html"><span class="nav-num">01</span>Findings</a></li>
<li><a href="02-architecture.html"><span class="nav-num">02</span>Architecture</a></li>
<li><a href="10-tenancy-model.html"><span class="nav-num">10</span>Tenancy Model</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Validation</h4>
<ul>
<li><a href="03-code-sketch.html"><span class="nav-num">03</span>Code Sketch</a></li>
<li><a href="04-ui-rule-config.html"><span class="nav-num">04</span>UI Rule Config</a></li>
<li><a href="05-result-pattern.html"><span class="nav-num">05</span>Result Pattern</a></li>
<li><a href="06-reconciliation.html" class="active"><span class="nav-num">06</span>Reconciliation</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Tenant Surface</h4>
<ul>
<li><a href="07-ui-schema.html"><span class="nav-num">07</span>UI Schema</a></li>
<li><a href="08-i18n-l10n.html"><span class="nav-num">08</span>i18n / l10n</a></li>
<li><a href="09-extension-fields.html"><span class="nav-num">09</span>Extension Fields</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Frontend</h4>
<ul>
<li><a href="../mockups/classic.html"><span class="nav-num">UI1</span>AppShell — Classic</a></li>
<li><a href="../mockups/zoho.html"><span class="nav-num">UI2</span>AppShell — 2-Stage</a></li>
<li><a href="../mockups/exam-setup.html"><span class="nav-num">UI3</span>AppShell — Exam Setup</a></li>
<li><a href="../mockups/ux-showcase-1.html"><span class="nav-num">UI4</span>AppShell — UX Showcase</a></li>
<li><a href="../mockups/ux-showcase-2.html"><span class="nav-num">UI5</span>AppShell — UX Showcase 2</a></li>
<li><a href="../mockups/ux-showcase-3.html"><span class="nav-num">UI6</span>AppShell — UX Showcase 3</a></li>
<li><a href="../mockups/ux-showcase-4.html"><span class="nav-num">UI8</span>AppShell — UX Showcase 4</a></li>
<li><a href="../mockups/ux-catalog.html"><span class="nav-num">UI7</span>UX Pattern Catalog</a></li>
<li><a href="../mockups/notes.html"><span class="nav-num">UIN</span>AppShell Notes</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Decisions</h4>
<ul>
<li><a href="../adr/0001-hybrid-validation-strategy.html"><span class="nav-num">ADR-1</span>Hybrid Validation</a></li>
<li><a href="../adr/0002-database-per-client.html"><span class="nav-num">ADR-2</span>DB-per-Client</a></li>
<li><a href="../adr/0003-frontend-stack.html"><span class="nav-num">ADR-3</span>Frontend Stack</a></li>
<li><a href="../adr/0004-authorization-permission-model.html"><span class="nav-num">ADR-4</span>Authz & Permissions</a></li>
<li><a href="../adr/0005-repo-topology.html"><span class="nav-num">ADR-5</span>Repo Topology</a></li>
<li><a href="../adr/0006-no-module-federation.html"><span class="nav-num">ADR-6</span>No Module Federation</a></li>
<li><a href="../adr/0007-ui-stack-radix-cssmodules.html"><span class="nav-num">ADR-7</span>UI Stack — Radix</a></li>
<li><a href="../adr/0008-mtp-web-folder-structure.html"><span class="nav-num">ADR-8</span>mtp-web Folder Structure</a></li>
<li><a href="../adr/0009-hr-payroll-three-aggregates.html"><span class="nav-num">ADR-9</span>HR-Payroll — 3 Aggregates, 1 Module</a></li>
</ul>
</div>
<div class="nav-group">
<h4>GNUMS — Conventions</h4>
<ul>
<li><a href="../gnums/module-catalog.html"><span class="nav-num">MOD</span>Module Catalog</a></li>
<li><a href="../gnums/all-modules-inventory.html"><span class="nav-num">REF</span>Procedure Inventory</a></li>
<li><a href="../gnums/org-hierarchy.html"><span class="nav-num">ORG</span>Org Hierarchy</a></li>
<li><a href="../gnums/hierarchy-comparison.html"><span class="nav-num">CMP</span>Hierarchy Comparison</a></li>
</ul>
</div>
<div class="nav-group">
<h4>GNUMS — Modules</h4>
<ul>
<li><a href="../gnums/methodology.html"><span class="nav-num">HOW</span>Study Methodology</a></li>
<li><a href="../gnums/accounting/scope.html"><span class="nav-num">ACC</span>Accounting — Scope</a></li>
<li><a href="../gnums/staff/scope.html"><span class="nav-num">STF</span>Staff — Scope</a></li>
<li><a href="../gnums/hr-payroll/scope.html"><span class="nav-num">HRP</span>HR-Payroll — Scope</a></li>
<li><a href="../gnums/exam/scope.html"><span class="nav-num">EXM</span>Exam — Scope</a></li>
<li><a href="../gnums/exam/strategy.html"><span class="nav-num">PLAY</span>Exam — Strategy</a></li>
<li><a href="../gnums/student/scope.html"><span class="nav-num">STU</span>Student — Scope</a></li>
<li><a href="../gnums/student/strategy.html"><span class="nav-num">SPL</span>Student — Strategy</a></li>
<li><a href="../gnums/student/cluster-01-core-identity.html"><span class="nav-num">SC1</span>Student / Core Identity</a></li>
<li><a href="../gnums/student/cluster-02-subordinate-entities.html"><span class="nav-num">SC2</span>Student / Subordinate Entities</a></li>
<li><a href="../gnums/student/cluster-03-master-config.html"><span class="nav-num">SC3</span>Student / Master & Config</a></li>
<li><a href="../gnums/student/cluster-04-certificate-workflow.html"><span class="nav-num">SC4</span>Student / Certificate Workflow</a></li>
<li><a href="../gnums/student/cluster-05-bvm-activities.html"><span class="nav-num">SC5</span>Student / NCC NSS Yoga</a></li>
<li><a href="../gnums/student/cluster-06-mentoring.html"><span class="nav-num">SC6</span>Student / Mentoring</a></li>
</ul>
</div>
</nav>
</aside>
<main>
<span class="eyebrow">06 · Bridge</span>
<h1>Reconciling the pattern with the existing house style</h1>
<p class="lede">
Page <a href="05-result-pattern.html">05</a> describes the principle with a standalone
<code>Result<T></code> that owns <code>ValidationIssue</code> directly — pedagogical.
The real codebase already has <code>ErrorOr<T></code>, <code>ValidationProblemDetails</code>,
and <code>ApiController<TTokenData></code>. This page shows the production-shaped
<code>Result<T></code> that wraps <code>ErrorOr<T></code> and adds advisories — keep
house types, add the minimum needed for Advisory and Confirmable.
</p>
<h2>What exists today (GNSchoolERP.API)</h2>
<table>
<thead><tr><th>Piece</th><th>Type</th><th>Role</th></tr></thead>
<tbody>
<tr><td>Service results</td><td><code>ErrorOr<T></code></td><td>Discriminated union: <code>Value</code> XOR <code>Errors</code></td></tr>
<tr><td>Error envelope</td><td><code>ValidationProblemDetails</code></td><td>Uniform RFC 7807 shape; <code>errors[Code] = [message]</code></td></tr>
<tr><td>Error type</td><td><code>ErrorOr.Error</code> + <code>ErrorType</code></td><td>Validation / NotFound / Conflict / Unauthorized / Forbidden / Failure / Unexpected</td></tr>
<tr><td>Controller base</td><td><code>ApiController<TTokenData></code></td><td><code>Problem(List<Error>)</code> + <code>DetermineStatusCode</code></td></tr>
<tr><td>Exception handler</td><td><code>GlobalExceptionHandler</code></td><td>Maps <code>SqlException</code>, <code>ValidationException</code>, etc. to <code>ValidationProblemDetails</code></td></tr>
<tr><td>HTTP status convention</td><td>—</td><td>Validation → <strong>400</strong> (not 422); Conflict → 409; NotFound → 404; Unauthorized → 403</td></tr>
</tbody>
</table>
<h2>Where the new pattern collides</h2>
<table>
<thead><tr><th>Need</th><th>ErrorOr fit?</th><th>Resolution</th></tr></thead>
<tbody>
<tr><td>Blocking issue</td><td>Fits — <code>Error.Validation(code, msg)</code></td><td>Use as today</td></tr>
<tr><td>Confirmable issue</td><td>Partial — no native type</td><td>Add <code>Error.Confirmable(...)</code> extension using <code>Error.Custom</code> with type code 550</td></tr>
<tr><td>Advisory (travels with success)</td><td>Does not fit — ErrorOr is Value XOR Errors</td><td>Wrap in <code>Result<T></code> that carries <code>ErrorOr<T></code> + advisories</td></tr>
</tbody>
</table>
<h2>The two additions</h2>
<h3>1. <code>Error.Confirmable</code> extension</h3>
<pre><code class="language-csharp">// GNWebSoft.AspNetCore / Results / ErrorExtensions.cs
public static class ErrorExtensions
{
public const int ConfirmableType = 550;
public static Error Confirmable(string code, string description, Dictionary<string, object?>? metadata = null) =>
Error.Custom(ConfirmableType, code, description, metadata);
public static bool IsConfirmable(this Error error) =>
error.Type == (ErrorType)ConfirmableType;
}
</code></pre>
<p>
Type code <code>550</code> is the wire-level discriminator. <em>5xx</em> is reserved by ErrorOr
for custom values (built-ins are 0–6). Stays out of the way of future ErrorOr additions.
</p>
<h3>2. <code>Result<T></code> wrapper for advisories on success</h3>
<pre><code class="language-csharp">// GNWebSoft.AspNetCore / Results / Result.cs
public sealed record Advisory(string Code, string Message, string? Field = null, string? Source = null);
public readonly struct Result<T>
{
public ErrorOr<T> ErrorOr { get; }
public IReadOnlyList<Advisory> Advisories { get; }
private Result(ErrorOr<T> errorOr, IReadOnlyList<Advisory> advisories)
{
ErrorOr = errorOr;
Advisories = advisories;
}
public bool IsError => ErrorOr.IsError;
public bool RequiresAck => ErrorOr.IsError && ErrorOr.Errors.Any(e => e.IsConfirmable());
public bool HasBlocking => ErrorOr.IsError && ErrorOr.Errors.Any(e => !e.IsConfirmable());
public static Result<T> Ok(T value, params Advisory[] advisories) =>
new(value, advisories);
public static Result<T> Fail(params Error[] errors) =>
new(ErrorOr<T>.From(errors.ToList()), Array.Empty<Advisory>());
public static implicit operator Result<T>(T value) => Ok(value);
public static implicit operator Result<T>(Error error) => Fail(error);
public static implicit operator Result<T>(List<Error> errors) => Fail(errors.ToArray());
}
</code></pre>
<h2>Updates to <code>ApiController<TTokenData></code></h2>
<pre><code class="language-csharp">// Adds two things to existing base:
// - DetermineStatusCode handles Confirmable (550 -> 409)
// - Problem(Result<T>) helper emits advisories via ProblemDetails extensions
protected ActionResult Problem<T>(Result<T> result, Func<T, ActionResult> onSuccess)
{
if (!result.IsError)
{
var ok = onSuccess(result.ErrorOr.Value);
if (result.Advisories.Count > 0 && ok is ObjectResult or)
{
or.Value = WrapWithAdvisories(or.Value, result.Advisories);
}
return ok;
}
return Problem(result.ErrorOr.Errors);
}
private int DetermineStatusCode(List<Error> errors)
{
// Existing logic plus one branch:
if (errors.All(e => e.IsConfirmable())) return 409;
if (errors.Any(e => e.IsConfirmable()))
{
// Mixed: prioritize hard failures over confirmable
return DetermineStatusCode(errors.Where(e => !e.IsConfirmable()).ToList());
}
// ... existing switch unchanged ...
}
</code></pre>
<p>
Existing <code>Problem(List<Error> errors)</code> stays unchanged. New overload accepts
<code>Result<T></code> and delegates to the existing path for the error branch.
</p>
<h2>HTTP shape (unchanged envelope)</h2>
<table>
<thead><tr><th>Case</th><th>Status</th><th>Body shape</th></tr></thead>
<tbody>
<tr><td>Success, no advisories</td><td>200 / 201</td><td>Normal payload</td></tr>
<tr><td>Success with advisories</td><td>200 / 201</td><td><code>{ data: <payload>, advisories: [...] }</code> via wrap helper</td></tr>
<tr><td>Confirmable-only errors</td><td>409</td><td><code>ValidationProblemDetails</code>; <code>errors[Code]=[message]</code>; <code>Extensions["requiresAcknowledgement"] = true</code></td></tr>
<tr><td>Blocking validation errors</td><td>400</td><td><code>ValidationProblemDetails</code> (matches existing handler)</td></tr>
<tr><td>Mixed (blocking + confirmable)</td><td>400</td><td>Blocking wins; confirmable carried in same dict so client can render both</td></tr>
</tbody>
</table>
<div class="callout">
<strong>Note on 400 vs 422.</strong> This codebase uses 400 for validation
(<code>GlobalExceptionHandler</code> + <code>ApiController.DetermineStatusCode</code>). The
earlier sketch in <a href="05-result-pattern.html">05</a> used 422 for principle. Stick with
400 here — uniform with the existing handler and existing client code that already branches
on it.
</div>
<h2>Client acknowledgement payload</h2>
<p>Unchanged from the sketch — submit DTO carries a string-set of acknowledged codes:</p>
<pre><code class="language-csharp">public sealed class SubmitLoanApplicationRequest
{
// domain fields ...
public IReadOnlySet<string>? AcknowledgedIssueCodes { get; init; }
}
</code></pre>
<p>Server treats Confirmable errors as suppressed if their <code>Code</code> appears in the ack set. Server always re-evaluates all rules.</p>
<h2>Strategy implementation (revised)</h2>
<pre><code class="language-csharp">public async Task<Result<Guid>> ExecuteAsync(
SubmitLoanApplicationRequest input, CancellationToken ct)
{
var binding = bindings.Get(FeatureKey)!;
var ruleIssues = (await rules.EvaluateAsync(binding.FlowVersionId, "Submit", input, ct)).Issues;
var ack = input.AcknowledgedIssueCodes ?? new HashSet<string>();
var errors = new List<Error>();
var advisories = new List<Advisory>();
foreach (var issue in ruleIssues)
{
switch (issue.Kind)
{
case IssueKind.Blocking:
errors.Add(Error.Validation(issue.Code, issue.Message));
break;
case IssueKind.Confirmable when !ack.Contains(issue.Code):
errors.Add(ErrorExtensions.Confirmable(issue.Code, issue.Message));
break;
case IssueKind.Advisory:
advisories.Add(new Advisory(issue.Code, issue.Message, issue.Field, issue.Source));
break;
}
}
if (errors.Count > 0) return Result<Guid>.Fail(errors.ToArray());
var application = LoanApplication.Create(/* ... */);
await repo.AddAsync(application, ct);
return Result<Guid>.Ok(application.Id, advisories.ToArray());
}
</code></pre>
<h2>Controller call site (revised)</h2>
<pre><code class="language-csharp">[HttpPost]
public async Task<IActionResult> Submit(
SubmitLoanApplicationRequest request, CancellationToken ct)
{
var result = await _service.SubmitAsync(request, ct);
return Problem(result, id =>
CreatedAtAction(nameof(GetById), new { id }, new { id }));
}
</code></pre>
<h2>Where the additions live</h2>
<p>
<code>Result<T></code>, <code>Advisory</code>, <code>ErrorExtensions.Confirmable</code>,
and the <code>ApiController</code> overload land inside
<strong><code>GNWebSoft.AspNetCore</code></strong> — the package every API in the suite
already references. No new NuGet. Ship as part of the next minor release.
</p>
<h2>Why not a dedicated <code>GNWebSoft.Results</code> library</h2>
<p>Considered. Rejected for now. Reasons:</p>
<ul>
<li><strong>One real consumer today.</strong> GNSchoolERP.API alone needs this. Library = solution looking for users.</li>
<li><strong>Library tax.</strong> Versioning, semver discipline, breaking-change reviews, docs, release cadence. N+1 places to keep in sync.</li>
<li><strong>Premature abstraction.</strong> The shape will change once it meets reality. Internal types in one project are cheap to evolve; cross-project NuGet is not.</li>
<li><strong>ErrorOr already does 90%.</strong> The 10% gap is two small additions, both house-style. Replacing ErrorOr is bug surface for no clear win.</li>
</ul>
<p>Promote to a dedicated package when <em>all three</em> hold:</p>
<ol>
<li>2+ active projects independently want the wrapper</li>
<li>External NuGet consumers (not just your apps) need the envelope</li>
<li>Shape has been stable in production for 3+ months</li>
</ol>
<h2>Migration in GNSchoolERP.API</h2>
<ol>
<li>Add <code>Result<T></code>, <code>Advisory</code>, <code>ErrorExtensions</code> to <code>GNWebSoft.AspNetCore</code>. Ship minor version bump.</li>
<li>Extend <code>ApiController.DetermineStatusCode</code> with Confirmable branch (additive, no breaking change).</li>
<li>Existing endpoints continue using <code>ErrorOr<T></code> + <code>Problem(errors)</code> — no change required.</li>
<li>New endpoints that need Advisory/Confirmable opt in by returning <code>Result<T></code>.</li>
<li>Pilot on one low-risk feature first (e.g. one admin module). Measure UX of the 409 + ack-modal flow. Iterate.</li>
</ol>
<div class="doc-footer">
<a href="05-result-pattern.html">
<span class="label">← Previous</span>
<span class="title">05 · Result Pattern</span>
</a>
<a href="07-ui-schema.html" class="next">
<span class="label">Next</span>
<span class="title">07 · UI Schema →</span>
</a>
</div>
</main>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>mermaid.initialize({ startOnLoad: true, theme: 'default' });</script>
</body>
</html>