<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged dotnet</title>
<link>https://coderlegion.com/tag/dotnet</link>
<description>Powered by Question2Answer</description>
<item>
<title>Day 2: An Overview of Definition-Driven Architecture</title>
<link>https://coderlegion.com/33157/day-2-an-overview-of-definition-driven-architecture</link>
<description>&lt;p&gt;The previous post, &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/33030/day-1-why-erp-needs-a-definition-driven-framework&quot;&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;, ended by promising this one would be about which decisions a framework should make on its users' behalf.&lt;/p&gt;
&lt;p&gt;The difference between a framework and a library is usually explained as &quot;you call a library, a framework calls you.&quot; That describes the mechanism, not the price. The real price is that once control is inverted, the framework's defaults become your defaults. It decides what data looks like, how a request flows, and where the extension points are. You are free to disagree, and the cost of disagreeing is working around it, which is usually more expensive than not using it at all.&lt;/p&gt;
&lt;p&gt;So the question to ask about a framework is not what it can do. The more things it can do, the fewer decisions it has made for you, and the same decision has to be made again for every single form.&lt;/p&gt;
&lt;p&gt;This post answers the four questions a definition-driven framework has to settle first. Every other design choice is a consequence of these four answers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which layer holds the single source of truth&lt;/li&gt;
&lt;li&gt;Which layers are derived from it&lt;/li&gt;
&lt;li&gt;What is left to the application&lt;/li&gt;
&lt;li&gt;Where this whole approach does not hold&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of the four can be left blank. If the framework does not answer them, the people using it will answer them individually, and their answers will not agree. This module puts the truth in an entity, that one puts it in the table, a third puts it in a form configuration file on the front end. A good part of the three long-standing problems from the previous post grows out of exactly that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-which-layer-holds-the-single-source-of-truth&quot;&gt;1. Which layer holds the single source of truth&lt;/h3&gt;
&lt;p&gt;First, a distinction that is easy to blur: a Code-First stack is not short of definitions. The API contract is a definition, the migration script is a definition, so is the database's own metadata. The difference is not whether definitions exist. It is which way the arrow points. Those definitions are dumped out as products. Editing them is meaningless, because the next generation run overwrites them.&lt;/p&gt;
&lt;p&gt;So the question is not whether to have definition files. It is where the arrow should point, and there are only three candidates:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Candidate &lt;/th&gt;&lt;th&gt;Paradigm &lt;/th&gt;&lt;th&gt;The catch&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Code &lt;/td&gt;&lt;td&gt;Code-First &lt;/td&gt;&lt;td&gt;A type system is wide enough to hold any semantics you can think of. The price is that the only reader of the truth is the compiler, and changing the truth means going through compilation, packaging and deployment&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The database &lt;/td&gt;&lt;td&gt;Database-First &lt;/td&gt;&lt;td&gt;Data is the final fact, but the type system is too narrow&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Definitions &lt;/td&gt;&lt;td&gt;Definition-Driven &lt;/td&gt;&lt;td&gt;Has to satisfy two conditions at once&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Where the database is narrow: it knows that &lt;code&gt;total_amount&lt;/code&gt; is a &lt;code&gt;decimal(18,4)&lt;/code&gt;, but not whether that number is an amount, a quantity or an exchange rate. It does not know whether the value should be rounded, how many digits to show, whether it is required, whether it can be edited, or whether it belongs in the query list. Those semantics have nowhere to live, so they end up somewhere else, and the truth has scattered again.&lt;/p&gt;
&lt;h4 id=&quot;for-definitions-to-be-the-truth-two-conditions-neither-optional&quot;&gt;For definitions to be the truth, two conditions, neither optional&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Wide enough to carry semantics beyond structure&lt;/li&gt;
&lt;li&gt;They are data, not code, which was the whole subject of the previous post&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this framework, what a single field can declare falls into six categories:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Category &lt;/th&gt;&lt;th&gt;What is declared &lt;/th&gt;&lt;th&gt;Can the database hold it?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Structure &lt;/td&gt;&lt;td&gt;Field name, data type, length, default value &lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behavior &lt;/td&gt;&lt;td&gt;Required, read only, visible &lt;/td&gt;&lt;td&gt;Partly&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Presentation &lt;/td&gt;&lt;td&gt;Caption, control type, display format, width &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Semantics &lt;/td&gt;&lt;td&gt;What kind of number this is, which field carries the currency, which field carries the unit of measure &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Relationships &lt;/td&gt;&lt;td&gt;The related form, the field mapping, the display field &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Security &lt;/td&gt;&lt;td&gt;Data scope roles, sensitive data classification &lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Code can hold all six, but only as a stack of attributes, and once they are stacked someone still has to parse them back into data before anything can use them.&lt;/p&gt;
&lt;p&gt;The width of that declaration is deliberate. It has to cover the UI, the database and validation in one pass.&lt;/p&gt;
&lt;h4 id=&quot;a-single-source-of-truth-is-not-a-claim-it-is-a-dependency-direction&quot;&gt;A single source of truth is not a claim, it is a dependency direction&lt;/h4&gt;
&lt;p&gt;Any framework can write &quot;this framework treats X as the single source of truth&quot; in its documentation. To verify it, the dependency direction is enough: the layer holding the truth should depend on nobody, and everybody should depend on it.&lt;/p&gt;
&lt;p&gt;The definition layer package has an extremely short reference list. It does not know a database exists, or HTTP, or a UI. In the other direction, data access, business logic, the API contract, the client, and every front end all reference it.&lt;/p&gt;
&lt;p&gt;That direction buys two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The definition layer can be processed by tools on its own. A definition editor, a generator, a batch checking script: each needs to reference only this one package, without dragging in the entire database and API stack. The claim in the previous post that a thousand definitions can be swept by one script rests on this.&lt;/li&gt;
&lt;li&gt;AI benefits too. To make it understand a system, feeding it the definitions is more efficient than having it scan the whole code base, and the conclusions are more reliable. The difference is not file size. It is whether relationships are declared or inferred. Ask which forms an order touches, and reading the definitions means reading relationships that were already declared; reading the code means chasing types, chasing queries, chasing configuration, and then inferring intent, and inference gets things wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tokens saved are a side effect. As for the business judgments that definitions do not cover, the code still has to be read.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-which-layers-are-derived-from-definitions&quot;&gt;2. Which layers are derived from definitions&lt;/h3&gt;
&lt;p&gt;The previous post used plain language: the UI, the database, CRUD SQL, validation rules. The precise list comes in two kinds. Most of it is generation:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What is generated &lt;/th&gt;&lt;th&gt;From&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;The single record form layout &lt;/td&gt;&lt;td&gt;The form structure (master block plus detail block)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The list layout &lt;/td&gt;&lt;td&gt;The list fields the form structure declares&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The lookup window layout &lt;/td&gt;&lt;td&gt;The queryable fields the form structure declares&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The physical table structure &lt;/td&gt;&lt;td&gt;Every table in the form structure&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;INSERT / UPDATE / DELETE &lt;/td&gt;&lt;td&gt;The form structure's field set&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SELECT, including cross table joins &lt;/td&gt;&lt;td&gt;The form structure's fields and declared relationships&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The structure of the data container that crosses layers &lt;/td&gt;&lt;td&gt;The form structure's field set&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The other kind is evaluation. Here the framework does not build anything; it takes the rules and expressions declared in the definition and evaluates them. Validation rules are evaluated at save and delete. Computed fields are recalculated the moment the user changes a field they depend on.&lt;/p&gt;
&lt;p&gt;When a requirement converges into a new shared behavior, a matching mechanism grows. The query criteria screen for a report, for example, is essentially a set of fields plus editors, and a layout definition can describe it.&lt;/p&gt;
&lt;p&gt;Grouped by who consumes it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Form structure definition
│
├─ Generates
│   ├─ Three layouts: single record / list / lookup window  -&amp;gt;  screens on every front end
│   ├─ Physical table structure  -&amp;gt;  the database
│   ├─ Two kinds of SQL: write / query, including cross table joins  -&amp;gt;  data access
│   └─ The cross layer data container structure  -&amp;gt;  transport
│
└─ Evaluates
    ├─ Validation rules  -&amp;gt;  at save and delete
    └─ Computed fields  -&amp;gt;  the moment a dependent field changes
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;generating-code-or-generating-objects&quot;&gt;Generating code, or generating objects&lt;/h4&gt;
&lt;p&gt;Growing something out of a definition at build time and at run time produces completely different results:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A build time generator produces code. When the definition changes, you regenerate, recompile and redeploy. Not one step of the delivery path from the previous post has been removed, and everything saved earlier is handed back at the final gate.&lt;/li&gt;
&lt;li&gt;Run time derivation produces objects in memory. Swap the definition, and the next read is the new one. This is exactly where &quot;no compilation, packaging or deployment between changing a definition and having it take effect&quot; holds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two things are paid for it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Compile time type checking is gone. A misspelled field name is not discovered until run time, and how that debt gets repaid is the subject of &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-5e76d8564d72&quot;&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Cache invalidation goes from an optimization to a problem that must be solved. Definitions will be cached for performance, so the framework needs a notification mechanism that carries a definition changed on one node out to every other node.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-what-is-left-to-the-application&quot;&gt;3. What is left to the application&lt;/h3&gt;
&lt;p&gt;Where the framework should stop has two rules, one positive and one negative.&lt;/p&gt;
&lt;p&gt;The positive one: any behavior that is standardized and repetitive should converge into a framework mechanism.&lt;/p&gt;
&lt;p&gt;This is not merely about writing fewer lines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;There is one way to do each thing, and a fix goes in one place, which saves maintenance&lt;/li&gt;
&lt;li&gt;New joiners, consultants and whoever inherits the system learn it once, and understanding one form means understanding all of them, which saves training and is the most commonly underestimated of the two&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The symptoms of losing control listed in the previous post are all invoices issued by the absence of a general mechanism, and they are reissued per person, per year.&lt;/p&gt;
&lt;p&gt;The negative rule is a single test: would every ERP in the world do this the same way? What is the same belongs to the framework. What differs belongs to the application.&lt;/p&gt;
&lt;p&gt;By that line the framework stays out of three areas:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Business judgment: how a status transitions, when shipping must be blocked. Every company differs.&lt;/li&gt;
&lt;li&gt;SQL for reports and batch jobs: multi table joins, aggregation, dynamic criteria, performance tuning. Forcing these into definitions only makes them more complicated.&lt;/li&gt;
&lt;li&gt;The application shell: the login screen, connection settings, navigation menu, windows and theme.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Staying out is not the same as ignoring. Having handed those three over, the framework picks up two obligations it must get right:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A registry that maps a form identifier to the class you wrote&lt;/li&gt;
&lt;li&gt;A few extension points at unambiguous positions, so your logic can hook into the save and delete flows&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How well those two are done decides directly whether the application is pleasant or awkward to write, and a later chapter takes them apart.&lt;/p&gt;
&lt;p&gt;Handing work over does not reduce complexity either. What definitions can absorb is repetition, not the inherent difficulty of the business. The hard parts have not disappeared. They have been concentrated in one place and given to whoever knows why they are hard.&lt;/p&gt;
&lt;p&gt;The third area is clearest in the case study. The Northwind front end has one UI project shared by every front end, and only four hand written screens: the main window, login, connection settings, and the layer that hosts forms. All four are shell. Not one of the eight business forms needs screen code.&lt;/p&gt;
&lt;p&gt;You can see where the line is drawn at a glance: the hand written part is precisely the part definitions did not take over.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-architectural-patterns-three-sources-one-piece-from-each&quot;&gt;4. Architectural patterns: three sources, one piece from each&lt;/h3&gt;
&lt;p&gt;With the first three questions answered, the shape of the framework is fixed. Before the fourth, one description that gets applied to it deserves handling: a hybrid of N-Tier, Clean Architecture and MVVM. &quot;Hybrid&quot; turns into a decorative word very easily, so here is what was taken and what was not.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Source &lt;/th&gt;&lt;th&gt;What was taken &lt;/th&gt;&lt;th&gt;What was not&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;N-Tier &lt;/td&gt;&lt;td&gt;The layer boundaries and the cross layer data container. One request travels front end, API, business object, repository, data access infrastructure, database, and each layer knows only its neighbor. The most completely taken of the three &lt;/td&gt;&lt;td&gt;Nothing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Clean Architecture &lt;/td&gt;&lt;td&gt;The inward dependency direction from the first section, verifiable by opening the project reference lists &lt;/td&gt;&lt;td&gt;Its requirement on the domain. The pure approach gives every business concept its own strongly typed domain entity, and modeling a thousand tables one at a time is the scale cost from the previous post itself&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MVVM &lt;/td&gt;&lt;td&gt;All three roles are present. The model is the cross layer data container, the view is rendered from the layout definition, and the view model is a framework provided &lt;code&gt;DataObject&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Nothing; the only difference is who writes the view model&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That &lt;code&gt;DataObject&lt;/code&gt; carries the full job: wrapping the data container, exposing field reads and writes for screen binding, raising notifications for value changes and unsaved state, managing the start and cancellation of detail row editing, and loading, saving and deleting against the back end. The difference is that it is not hand written per form. Its shape comes from the definition. The price is that the screen binds not to a property like &lt;code&gt;order.CustomerId&lt;/code&gt; but to a field name.&lt;/p&gt;
&lt;p&gt;The cross layer data container is not a custom type either, and a later post is devoted to what that choice costs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-systems-this-should-not-be-applied-to&quot;&gt;5. Systems this should not be applied to&lt;/h3&gt;
&lt;p&gt;Three questions before you commit. If any one of them does not survive, seriously consider not applying this.&lt;/p&gt;
&lt;p&gt;Q1: Are your screens standardized forms?&lt;br&gt;
Definitions can drive layout only because the layout patterns converged, and what converged here is a block of master fields plus a detail block. Dashboards, charts, drag and drop scheduling and maps are not in the pattern. The framework's answer is not &quot;we support those too.&quot; It is that this road does not go through definitions, and you write it yourself.&lt;/p&gt;
&lt;p&gt;Q2: Is your request a single document?&lt;br&gt;
The cross layer data container is self describing, and the price is that every response carries the field structure with it. For one order that is worth it. For an event stream at tens of thousands per second it is not.&lt;/p&gt;
&lt;p&gt;Q3: How long does your system have to live?&lt;br&gt;
The cost is concentrated up front and the return does not show for three to five years. A system that ships, closes and gets rewritten wholesale three years later pays the cost and never collects.&lt;/p&gt;
&lt;p&gt;Not many scenarios satisfy all three at once, and internal enterprise management systems happen to hit every one. Finance, purchasing, inventory, sales, HR, customer relations: the screens are almost all standardized forms, the requests are almost all documents, and once it goes live it lives a long time.&lt;/p&gt;
&lt;p&gt;These boundaries are not &quot;the framework has not got there yet.&quot; They are places where getting there would make things worse. A framework that can do anything has made no decisions for you, and the decisions are exactly where a framework's value is.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-northwind-case-study-how-much-one-definition-does&quot;&gt;The Northwind case study: how much one definition does&lt;/h3&gt;
&lt;p&gt;The order in the case study has a single form structure definition, and it states:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One master with one detail&lt;/li&gt;
&lt;li&gt;Three cross table relationships on the master, to customer, employee and shipper, each a lookup window that brings one record back&lt;/li&gt;
&lt;li&gt;Each detail row picks a product&lt;/li&gt;
&lt;li&gt;Amounts are not typed by anyone. They are calculated from quantity, unit price and discount&lt;/li&gt;
&lt;li&gt;Three validation rules have to pass before saving&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once that one definition is written, everything in the table from the second section is in place. The tables are created from it, the list and lookup window layouts grow directly out of it, the write and query SQL is assembled by the framework, the data carries its structure with it across layers, and the validation rules are checked at the moment of saving. Not one of those requires additional code.&lt;/p&gt;
&lt;p&gt;The same definition is read by four front ends: desktop, browser, iOS and Android, all sharing one UI project.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The value of a framework is not how much it can do. It is whether it can tell which decisions belong to it: the thing that is identical a thousand times over is its, and the thing that differs every time is yours. Draw that line crooked and both sides carry the weight. Draw it too conservatively and you redo the same work a thousand times. Draw it too greedily and you spend more effort working around it.&lt;/p&gt;
&lt;p&gt;The next post, &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33&quot;&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, brings the case study on stage.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;📚 Part of the thirty-post series &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/33029/erp-architects-notes-designing-a-definition-driven-framework-series-index&quot;&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377/day-02-framework-decisions&quot;&gt;https://hackmd.io/@jeff377/day-02-framework-decisions&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/profile.php?id=61574839666569&quot;&gt;Facebook&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377&quot;&gt;HackMD&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377&quot;&gt;GitHub&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/profiles/jeff377&quot;&gt;NuGet&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33157/day-2-an-overview-of-definition-driven-architecture</guid>
<pubDate>Sat, 31 Oct 2026 01:00:08 +0000</pubDate>
</item>
<item>
<title>How to Build Better Full Stack Applications by Solving Small Problems First</title>
<link>https://coderlegion.com/33044/how-to-build-better-full-stack-applications-by-solving-small-problems-first</link>
<description>&lt;p&gt;As a full stack developer, I’ve learned that building a web application is not just about writing code or choosing the right framework. It’s about understanding problems, finding practical solutions, and making sure everything works together smoothly.&lt;/p&gt;
&lt;p&gt;When working with technologies like .NET, SQL Server, JavaScript, MVC, and Angular, even a small issue can affect the entire application.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-small-issues-can-become-big-problems&quot;&gt;The Problem: Small Issues Can Become Big Problems&lt;/h3&gt;
&lt;p&gt;Imagine building a web application with a login page, a dashboard, and a database. Everything seems fine during development, but after deployment, users start experiencing slow page loading, incorrect data, or unexpected errors.&lt;/p&gt;
&lt;p&gt;These problems often happen because different parts of the application are developed without considering how they interact with each other.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A SQL Server query takes too long to return results.&lt;/li&gt;
&lt;li&gt;An API returns more data than the frontend actually needs.&lt;/li&gt;
&lt;li&gt;A form submits duplicate requests when a user clicks the button multiple times.&lt;/li&gt;
&lt;li&gt;An Angular component displays outdated information.&lt;/li&gt;
&lt;li&gt;An MVC controller contains too much business logic, making it difficult to maintain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each issue may look small on its own, but together they can affect performance, reliability, and the user experience.&lt;/p&gt;
&lt;h3 id=&quot;how-i-approach-these-problems&quot;&gt;How I Approach These Problems&lt;/h3&gt;
&lt;p&gt;Instead of trying to fix everything at once, I prefer breaking the problem into smaller pieces.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Understand the root cause&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Before changing code, I try to understand what is actually going wrong. Checking error messages, reviewing application logs, inspecting network requests, and examining SQL queries can help identify the real issue.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Keep the backend organized&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In .NET applications, separating responsibilities makes the code easier to understand and maintain. Controllers should handle HTTP requests, while business logic and database operations should be organized appropriately.&lt;/p&gt;
&lt;p&gt;This structure also makes testing and future improvements easier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Optimize database operations&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;SQL Server performance can significantly affect the overall application. Reviewing queries, checking execution plans, adding appropriate indexes, and retrieving only the required data can help reduce unnecessary work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Improve frontend communication&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The frontend and backend need to communicate reliably. Using clear API responses, handling loading states, validating user input, and displaying useful error messages can make an application more predictable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Test the complete workflow&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A feature might work perfectly in isolation but fail when connected to the database or another component. Testing the entire workflow helps uncover issues that individual unit tests may not catch.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-example&quot;&gt;A Practical Example&lt;/h3&gt;
&lt;p&gt;Consider a product management page where users can search, filter, and update thousands of records.&lt;/p&gt;
&lt;p&gt;A simple approach might load every record into the browser and filter the results using JavaScript. This may work with a small dataset, but performance can deteriorate as the data grows.&lt;/p&gt;
&lt;p&gt;A better solution is to implement server-side pagination and filtering.&lt;/p&gt;
&lt;p&gt;The Angular frontend sends the search term, page number, and page size to a .NET API. The backend validates the request and retrieves only the necessary records from SQL Server.&lt;/p&gt;
&lt;p&gt;The API returns the requested data along with pagination information, and the frontend displays the results.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The result is less unnecessary data transfer, better scalability, and a smoother user experience.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This example shows why good full stack development requires thinking beyond a single component.&lt;/p&gt;
&lt;h3 id=&quot;where-ai-can-help&quot;&gt;Where AI Can Help&lt;/h3&gt;
&lt;p&gt;AI tools can also be useful during development. They can help explain unfamiliar code, suggest SQL optimizations, generate test cases, and identify possible edge cases.&lt;/p&gt;
&lt;p&gt;However, generated solutions still need to be reviewed and tested. A suggested query may be logically correct but inefficient for a particular database. A generated API implementation may overlook authorization or input validation.&lt;/p&gt;
&lt;p&gt;I see AI as a development assistant rather than a replacement for understanding the application.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Building reliable web applications is a continuous process of identifying problems, testing assumptions, and improving solutions.&lt;/p&gt;
&lt;p&gt;Whether I’m working with .NET, SQL Server, MVC, JavaScript, or Angular, I believe the goal should be to create software that is easy to understand, maintain, and improve over time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Good development is not about writing more code. It’s about solving the right problem with a solution that works today and remains useful tomorrow.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I'm interested in learning how other developers approach these challenges.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What do you usually check first when a web application becomes slow: the frontend, the API, or the database?&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33044/how-to-build-better-full-stack-applications-by-solving-small-problems-first</guid>
<pubDate>Fri, 30 Oct 2026 05:33:07 +0000</pubDate>
</item>
<item>
<title>ERP Architect's Notes: Designing a Definition-Driven Framework · Series Index</title>
<link>https://coderlegion.com/33029/erp-architects-notes-designing-a-definition-driven-framework-series-index</link>
<description>&lt;blockquote&gt;&lt;p&gt;The complexity of an ERP is not in how hard any single feature is. It is in how many times the same fact gets restated, multiplied by how many tables, multiplied again by how many customizations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A thirty-post series written for &lt;a rel=&quot;nofollow&quot; href=&quot;https://ithelp.ithome.com.tw/users/20007956/ironman/9251&quot;&gt;iThome Ironman 2026&lt;/a&gt;, a thirty-day technical writing challenge in Taiwan, published from 2026-08-17 to 2026-09-15. The series is complete.&lt;/p&gt;
&lt;p&gt;This series is not about building one form with a framework. It is about designing a framework that can drive hundreds of forms: the single source of truth lives in definitions rather than code, and the database structure, SQL, screens for multiple front ends, validation and computation, multiple languages, multi-tenancy and customization are all derived from the same layer of definitions. The Northwind case study verifies it, but the case study is evidence, not the subject.&lt;/p&gt;
&lt;p&gt;It is about mechanisms and trade-offs, not a usage tutorial, and the cost of every design is written as plainly as its benefits.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;chapter-one-why-definition-driven&quot;&gt;Chapter One · Why Definition-Driven&lt;/h3&gt;
&lt;p&gt;What the problem is, where the paradigms diverge, and the case study's first appearance. This chapter sets side by side how many places one field touches under each paradigm.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 1 · &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/33030/day-1-why-erp-needs-a-definition-driven-framework&quot;&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Day 2 · &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/33157/day-2-an-overview-of-definition-driven-architecture&quot;&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Day 3 · The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-two-definition-layer&quot;&gt;Chapter Two · Definition Layer&lt;/h3&gt;
&lt;p&gt;What kinds of definition files there are, why their lifecycles differ, and how one structural description grows into the database structure, CRUD SQL, screens for multiple front ends and business rules.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 4 · The Purpose and Classification of Definition Files&lt;/li&gt;
&lt;li&gt;Day 5 · How FormSchema Drives SQL&lt;/li&gt;
&lt;li&gt;Day 6 · TableSchema and Database Structure Upgrades&lt;/li&gt;
&lt;li&gt;Day 7 · FormLayout and Screen Generation for Multiple Front Ends&lt;/li&gt;
&lt;li&gt;Day 8 · Declarative Business Rules and Computed Fields&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-three-data-access-and-caching&quot;&gt;Chapter Three · Data Access and Caching&lt;/h3&gt;
&lt;p&gt;Without writing DTOs, how data crosses layers; the only two things multiple databases have in common; and how a cache knows its source has changed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 9 · Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/li&gt;
&lt;li&gt;Day 10 · Multi-Database Support: CRUD and Structure Upgrades&lt;/li&gt;
&lt;li&gt;Day 11 · Connections, Transactions and Query Conditions in the Data Access Layer&lt;/li&gt;
&lt;li&gt;Day 12 · Cross-Node Invalidation for Definition and Data Caches&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-four-business-logic-and-api&quot;&gt;Chapter Four · Business Logic and API&lt;/h3&gt;
&lt;p&gt;Who takes over the part definitions cannot express, where the extension points open, and what a call passes through on its way from the caller to the business object.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 13 · Session Management and Lifecycle&lt;/li&gt;
&lt;li&gt;Day 14 · Extension Points on a Business Object and When to Override Them&lt;/li&gt;
&lt;li&gt;Day 15 · API Contract Layering and the Type Registry&lt;/li&gt;
&lt;li&gt;Day 16 · JSON-RPC 2.0: Protocol Choice and the Request Pipeline&lt;/li&gt;
&lt;li&gt;Day 17 · The Connector and the Convergence of Client Responsibilities&lt;/li&gt;
&lt;li&gt;Day 18 · The Error Contract and User Visible Messages&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-five-multi-tenancy-and-customization&quot;&gt;Chapter Five · Multi-Tenancy and Customization&lt;/h3&gt;
&lt;p&gt;How one codebase lets every company look different without branching and without recompiling or redeploying. Where the customization work currently stops is stated plainly too.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 19 · The Scope of the Customization Layer and Overlay Granularity&lt;/li&gt;
&lt;li&gt;Day 20 · Multilingual Customization and Resource Structure&lt;/li&gt;
&lt;li&gt;Day 21 · Business Logic Customization and the Four Plugin Stages&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-six-transport-security-and-audit&quot;&gt;Chapter Six · Transport, Security and Audit&lt;/h3&gt;
&lt;p&gt;What data looks like when it is sent, which protections it passes through, who is allowed to call, and what can be looked up afterward.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 22 · The Division of Labor Between XML, JSON and MessagePack&lt;/li&gt;
&lt;li&gt;Day 23 · The API Payload Security Pipeline: Order, Protection Levels and Keys&lt;/li&gt;
&lt;li&gt;Day 24 · The Three Axes of Permissions: Action, Row and Column&lt;/li&gt;
&lt;li&gt;Day 25 · The Audit Trail and the Before and After Values of a Change&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-seven-data-semantics&quot;&gt;Chapter Seven · Data Semantics&lt;/h3&gt;
&lt;p&gt;Amounts, quantities, dates and times of day: how the meaning of these values is kept from the definition all the way to the database, without any layer quietly changing it along the way.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 26 · Numeric Semantics, Multiple Currencies and Units of Measure&lt;/li&gt;
&lt;li&gt;Day 27 · The Semantic Boundaries Between Date, DateTime and Time&lt;/li&gt;
&lt;li&gt;Day 28 · Time Zone Conversion of DateTime&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;chapter-eight-case-review-and-the-big-picture&quot;&gt;Chapter Eight · Case Review and The Big Picture&lt;/h3&gt;
&lt;p&gt;The Northwind application examined item by item: which part definitions, the framework and application code each own. Then a single big picture, and the systems this design does not suit.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Day 29 · Which Part Definitions, the Framework and Application Code Each Own&lt;/li&gt;
&lt;li&gt;Day 30 · Three Depths of Development and the Big Picture of the Framework&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-the-framework-behind-this-series&quot;&gt;About the framework behind this series&lt;/h3&gt;
&lt;p&gt;The mechanisms discussed in the series are all built into an open source framework, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377/bee-library&quot;&gt;bee-library&lt;/a&gt;. The case study is a separate repository, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377/bee-northwind-avalonia&quot;&gt;bee-northwind-avalonia&lt;/a&gt;, which pulls the framework in as a package and holds only the application's own definition files and code.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Update: Bee.NET (bee-library), the framework used when this series was written, has since been taken over by &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem&quot;&gt;Polhem&lt;/a&gt;. The mechanisms carry over, the framework has been substantially refactored, and it is open source under the MIT license. The bee-library and bee-northwind-avalonia repositories are archived; type names and code in the posts follow bee-library as it was at the time of writing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The framework's only role in this series is to show that these ideas are not just talk on paper. You can skip it entirely, take away only the idea of letting definitions be the source, and build that idea in the languages and tools you know.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;📘 Original index on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377/ironman-2026&quot;&gt;https://hackmd.io/@jeff377/ironman-2026&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/profile.php?id=61574839666569&quot;&gt;Facebook&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377&quot;&gt;HackMD&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377&quot;&gt;GitHub&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/profiles/jeff377&quot;&gt;NuGet&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33029/erp-architects-notes-designing-a-definition-driven-framework-series-index</guid>
<pubDate>Fri, 30 Oct 2026 01:23:04 +0000</pubDate>
</item>
<item>
<title>Day 1: Why ERP Needs a Definition-Driven Framework</title>
<link>https://coderlegion.com/33030/day-1-why-erp-needs-a-definition-driven-framework</link>
<description>&lt;p&gt;This is the first post in a thirty-part series, ERP Architect's Notes: Designing a Definition-Driven Framework.&lt;/p&gt;
&lt;p&gt;What follows is not about building one form with some framework. It is about designing a framework that can drive hundreds of forms: which decisions it should make on your behalf, which ones it should leave to you, and why each mechanism ended up shaped the way it is. Every claim is checked against a working application built on the Northwind sample database, but the case study is evidence, not the subject.&lt;/p&gt;
&lt;p&gt;This post covers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The three layers of ERP development cost: a single change, scale, and delivering customization&lt;/li&gt;
&lt;li&gt;The three long-standing problems those layers converge on, and why tooling does not remove them&lt;/li&gt;
&lt;li&gt;The point where the paradigms diverge: is the single source of truth code, or definitions?&lt;/li&gt;
&lt;li&gt;A controlled experiment: how many places does one field touch under each paradigm?&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;who-i-am&quot;&gt;Who I am&lt;/h3&gt;
&lt;p&gt;I have spent more than twenty years as a software architect in packaged ERP and HRM products. Packaged software has a very long product cycle, ten or twenty years is normal, and over that span the system is not merely maintained. It has to grow a new face every time the front-end generation turns over. I have been through three of them: desktop, web, and mobile apps. Each time the question was the same. Do we rebuild all several hundred screens?&lt;/p&gt;
&lt;p&gt;On a product line like that, an architectural decision does not get rewritten by someone else three years later. You watch it grow for ten years and then keep maintaining it. I stayed in the same role the whole time. I never moved into managing projects or teams and left the code behind, so I did not miss the consequences of a single one of those decisions.&lt;/p&gt;
&lt;h3 id=&quot;about-the-framework-and-about-this-series&quot;&gt;About the framework, and about this series&lt;/h3&gt;
&lt;p&gt;Some of the ideas I accumulated over those years were built inside real projects. More of them were only ever thought through, because writing all of it alone was never worth the cost. That cost is different now. I used Claude Code to build the ones I never got to, and the result is an open source framework, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377/bee-library&quot;&gt;bee-library&lt;/a&gt;. The Northwind case study in later posts runs on it.&lt;/p&gt;
&lt;p&gt;The case study itself lives in a separate repository, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377/bee-northwind-avalonia&quot;&gt;bee-northwind-avalonia&lt;/a&gt;. The framework repository holds the mechanisms; the case study repository holds only the application's own definition files and code, with the framework pulled in as a package. If you want to know what applying this framework actually requires, which definition files you prepare and what code you still have to write, the case study repository shows it more clearly than the framework does.&lt;/p&gt;
&lt;p&gt;The framework is still moving. Things that deserve to become mechanisms will keep being folded in, interfaces will change with them, and breaking changes will happen. If you want to reference the packages directly today, that has to be part of your calculation. You can also clone the whole thing and shape it into what you need.&lt;/p&gt;
&lt;p&gt;This series is not a manual for it. The framework has exactly one job here: to show that these ideas are not just talk. What the thirty posts discuss is the mechanisms that already exist, what problem each one ran into, how it was solved, what it cost, and why that option rather than another. Implementation details are not walked through line by line. You are entirely free not to use it, and to take only the idea that definitions should be the source, and build it in whatever language and tools you already know.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The whole series rests on one sentence:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The complexity of an ERP is not in how hard any single feature is. It is in how many times the same fact gets restated, multiplied by how many tables, multiplied again by how many customizations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-cost-of-a-single-change-how-many-places-one-field-lands-in&quot;&gt;1. The cost of a single change: how many places one field lands in&lt;/h3&gt;
&lt;p&gt;The requirement: orders need one more field, Required Date. No calculation, no dependent behavior, no effect on existing logic. The user says this is &quot;just one field, it should be easy,&quot; and the user is right.&lt;/p&gt;
&lt;p&gt;In a typical Code-First stack, ORM plus web API plus front end, that field lands in thirteen places:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;# &lt;/th&gt;&lt;th&gt;Location &lt;/th&gt;&lt;th&gt;What changes&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;Entity class &lt;/td&gt;&lt;td&gt;Add a property&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;Type mapping configuration &lt;/td&gt;&lt;td&gt;Precision, nullability, column name mapping&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;Migration &lt;/td&gt;&lt;td&gt;Generate it, review it, commit it, run it at deployment&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4 &lt;/td&gt;&lt;td&gt;Query projections &lt;/td&gt;&lt;td&gt;The &lt;code&gt;Select&lt;/code&gt; for list queries, the column list for reports&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5 &lt;/td&gt;&lt;td&gt;DTOs &lt;/td&gt;&lt;td&gt;Once for the request, once for the response&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;6 &lt;/td&gt;&lt;td&gt;Mapper configuration &lt;/td&gt;&lt;td&gt;Entity to DTO&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;7 &lt;/td&gt;&lt;td&gt;Validation &lt;/td&gt;&lt;td&gt;Required, range, ordering relative to the order date&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;API contract &lt;/td&gt;&lt;td&gt;The OpenAPI schema, republished to consumers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;9 &lt;/td&gt;&lt;td&gt;Front-end types &lt;/td&gt;&lt;td&gt;A TypeScript interface or a C# model&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;10 &lt;/td&gt;&lt;td&gt;Front-end form &lt;/td&gt;&lt;td&gt;Control, label, position in the layout&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;11 &lt;/td&gt;&lt;td&gt;Front-end list &lt;/td&gt;&lt;td&gt;Column, width, sorting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;12 &lt;/td&gt;&lt;td&gt;Localization resources &lt;/td&gt;&lt;td&gt;One per language&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;13 &lt;/td&gt;&lt;td&gt;Test data &lt;/td&gt;&lt;td&gt;Fixtures, seeds, fake data generators&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;To be fair, several of those can be generated. Migrations come from a tool, OpenAPI is produced by scanning assemblies, front-end types can be generated from OpenAPI. The number you actually hand write is closer to five or six.&lt;/p&gt;
&lt;p&gt;But the number you hand write is not the point:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The single fact that &quot;Required Date is a date, it is required, and it shows after the order date&quot; has been restated thirteen times in this stack.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every restatement is a place where the copies can disagree:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Forgot the DTO, and the value reaches the database but never the front end&lt;/li&gt;
&lt;li&gt;Forgot the validation, and users submit it empty&lt;/li&gt;
&lt;li&gt;Forgot the list column, and users ask why they cannot see it on the query screen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are hard problems. They are counting problems, and each inconsistency has to be found by a test or by a user report.&lt;/p&gt;
&lt;h4 id=&quot;a-team-boundary-runs-straight-through-that-table&quot;&gt;A team boundary runs straight through that table&lt;/h4&gt;
&lt;p&gt;Items 1 through 8 are back end. Items 9 through 13 are front end. If those are two teams, the line cuts through the middle of the table, and every item that crosses it changes character. It stops being editing and becomes coordination.&lt;/p&gt;
&lt;p&gt;A request to add one field then grows into this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A meeting to agree the contract first, then separate scheduling on each side&lt;/li&gt;
&lt;li&gt;Sequencing: until the back end ships there is nothing for the front end to consume, so starting early means hand rolling a mock&lt;/li&gt;
&lt;li&gt;Release windows: whichever side goes first has to cope with the other not having caught up&lt;/li&gt;
&lt;li&gt;And during integration, the discovery that one side read &quot;required&quot; as a front end block and the other read it as a back end block&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Not one of those activities is writing the field. What they consume is not effort, it is waiting. The user counts the time from asking to shipping; the engineer counts the time actually spent working. So one side asks why changing a single field takes this long, and the other side does not feel it was slacking. Both are right, because the cost is real, it just does not land on the part where anyone is typing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-the-cost-of-scale-multiply-that-table-by-a-thousand&quot;&gt;2. The cost of scale: multiply that table by a thousand&lt;/h3&gt;
&lt;p&gt;A full packaged ERP covers finance, purchasing, inventory, manufacturing, sales and HR. Business tables run into the thousands.&lt;/p&gt;
&lt;p&gt;The founding premise of an ORM is that objects map to tables, so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thousands of business tables
  -&amp;gt; thousands of entity classes
  -&amp;gt; plus DTOs, usually more than one: List / Detail / Create / Update
  -&amp;gt; plus mapper configuration
  -&amp;gt; plus repositories
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The overwhelming majority of those classes resemble each other. The entity for &lt;code&gt;Supplier&lt;/code&gt; and the entity for &lt;code&gt;Shipper&lt;/code&gt; differ in field names and field count; structurally they came from the same mold.&lt;/p&gt;
&lt;p&gt;This is not an argument that ORMs are bad. An ORM works well in the setting it was designed for. The problem is quantity. When the same pattern has to be copied a few thousand times, the copying itself becomes the dominant cost.&lt;/p&gt;
&lt;p&gt;Relationships then multiply that quantity again:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ERP tables are densely connected by nature. An order points at a customer, a salesperson and a shipper; a shipment points at an order, a warehouse and a lot number.&lt;/li&gt;
&lt;li&gt;Every one of those relationships needs someone to decide how it is expressed, how it is loaded, and who maintains it. Each decision is reasonable on its own. Spread a few thousand tables out at once and nobody can hold the whole picture.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Take the thirteen slots from the first section and multiply them by a few thousand, and multiply the team boundary along with them. Cross team coordination is not paid once per table, it is paid once per request, and the number of requests is itself proportional to the number of tables.&lt;/p&gt;
&lt;p&gt;This is not inconvenience. It is loss of control. Anyone who has maintained a large system recognizes the symptoms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Nobody dares touch shared classes, because nobody is sure who depends on them&lt;/li&gt;
&lt;li&gt;New joiners take a long time before they will go near core modules&lt;/li&gt;
&lt;li&gt;The same concept has several different spellings across modules, because different people grew them at different times&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-cost-of-customization-how-do-you-deliver-it-once-it-is-done&quot;&gt;3. The cost of customization: how do you deliver it once it is done&lt;/h3&gt;
&lt;p&gt;The first two layers are about how much work a change takes. The third one is more fundamental, and there is no way around it, because:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;An ERP that gets installed and simply used essentially does not exist.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every rollout comes with customization. This client numbers documents differently, that one calculates cost differently, this department wants two extra fields, that report needs a different grouping. The only variable is the path the customization travels down.&lt;/p&gt;
&lt;p&gt;Under Code-First, all thirteen of those places are code, and code has exactly one delivery path:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;edit -&amp;gt; compile -&amp;gt; test -&amp;gt; package -&amp;gt; deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same path hurts in three different ways depending on the deployment shape:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Deployment shape &lt;/th&gt;&lt;th&gt;Where customization hurts&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A single organization's internal system &lt;/td&gt;&lt;td&gt;The simplest case: schedule one maintenance window. But changing a decimal scale from 2 to 4 still means a full build, test and deploy&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Packaged software sold to many customers, each with their own deployment &lt;/td&gt;&lt;td&gt;Only two options. Put it in the trunk behind a switch, and the switches accumulate until nobody dares delete any of them. Or branch, and every branch evolves on its own while every fix in the trunk has to be carried across one at a time&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Multi-tenant SaaS on shared assemblies &lt;/td&gt;&lt;td&gt;Tenant A changes one formula, and that means coordinating a maintenance window across all tenants and regression testing every tenant's functionality. If any one tenant breaks, the whole batch rolls back&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three shapes, one conclusion:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The size of the requirement and the cost of delivering it have come completely apart.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Whether the requirement is changing a decimal scale from 2 to 4 or reworking an entire module, it travels the same delivery path and carries the same deployment risk. The result is that small requirements have to be scheduled into the release rhythm too, and the engineering team is forced to treat every change as a significant event.&lt;/p&gt;
&lt;p&gt;The root of that decoupling is that customization was expressed as code. The deployment shape only decides how much it hurts. It does not decide whether it hurts.&lt;/p&gt;
&lt;p&gt;This layer is what definition-driven design is actually for:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Definitions are data, not code. Changing data requires neither recompilation nor redeployment of assemblies.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-three-problems-that-never-got-solved&quot;&gt;4. Three problems that never got solved&lt;/h3&gt;
&lt;p&gt;The costs from the first three sections converge into these:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Long-standing problem &lt;/th&gt;&lt;th&gt;Symptom&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Specifications scattered across three layers &lt;/td&gt;&lt;td&gt;The same field is defined once in the UI, once in the DTO and once in a database migration, and they drift apart easily&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Business logic scattered &lt;/td&gt;&lt;td&gt;Different modules are maintained by different engineers, styles diverge, and the same work gets done more than once&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Customization that resists standardization &lt;/td&gt;&lt;td&gt;Custom logic cannot flow back into the standard product, and accumulates as technical debt nobody can govern&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For the past twenty years the usual answer has been more tooling: code generators, project templates, smarter mappers, more automatic migrations. Those tools genuinely made each restatement cheaper.&lt;/p&gt;
&lt;p&gt;What they did not change is the structure in which one fact gets restated thirteen times. They only made restating it faster.&lt;/p&gt;
&lt;p&gt;For the third layer, customization cost, tooling helps even less. Generated code is still code, and still has to be compiled and deployed. The reason the third problem has been open for twenty years is right here.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-where-the-paradigms-diverge&quot;&gt;5. Where the paradigms diverge&lt;/h3&gt;
&lt;p&gt;The problem is not the tooling. It is the paradigm. There is only one question to answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Is the single source of truth in this system the code, or the definitions?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Code-First: the entity class is the source. The database is derived from it through migrations, the API contract is derived from it through OpenAPI, and front-end types are derived from that. Definition files are a by-product.&lt;/li&gt;
&lt;li&gt;Definition-Driven: a structural description is the source. The UI, the database, the CRUD SQL and the validation rules are all derived from it. Code appears only where a real business judgment has to be made.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The difference is not that a file extension changed from &lt;code&gt;.cs&lt;/code&gt; to &lt;code&gt;.xml&lt;/code&gt;. It is the nature of the source:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&amp;nbsp; &lt;/th&gt;&lt;th&gt;Code is the source &lt;/th&gt;&lt;th&gt;Definitions are the source&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;How a thousand tables are represented &lt;/td&gt;&lt;td&gt;A thousand classes &lt;/td&gt;&lt;td&gt;A thousand definition records&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;What you can bring to bear on them &lt;/td&gt;&lt;td&gt;Compilers, IDEs, refactoring tools &lt;/td&gt;&lt;td&gt;All of that, plus batch scripts, generators, programmatic checks, and AI&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The delivery path for a change &lt;/td&gt;&lt;td&gt;Compile, package, deploy &lt;/td&gt;&lt;td&gt;Swap in a different definition&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How customization is represented &lt;/td&gt;&lt;td&gt;Branches or switches scattered through source &lt;/td&gt;&lt;td&gt;A layer of data stacked on top of the standard definition&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How the front end learns which fields exist &lt;/td&gt;&lt;td&gt;A second, hand-written set of types kept in sync by contract and by people &lt;/td&gt;&lt;td&gt;Fetching the same definition at run time&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;What a new front-end generation costs &lt;/td&gt;&lt;td&gt;Rebuilding every screen &lt;/td&gt;&lt;td&gt;Writing one new rendering layer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Who can change it &lt;/td&gt;&lt;td&gt;Developers &lt;/td&gt;&lt;td&gt;Developers, consultants, tools, and even users&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two of those rows deserve more than a line:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A thousand classes and a thousand definition records are the same count, but managing them is not the same order of magnitude. A thousand structurally identical XML files can be swept by one script that checks whether every amount field has the right decimal scale. That takes minutes. Doing the equivalent check across a thousand classes is considerably harder.&lt;/li&gt;
&lt;li&gt;Generational cost is the one I feel most strongly. Front-end technology turns over roughly every ten years. If screens are written by hand one at a time, a generation change means rebuilding all of them. If screens grow out of definitions, a generation change means writing one new rendering layer. The more forms you have, the wider the gap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is also the reason layout deserves to be its own layer of definition. Structure is stable and follows the business; layout is volatile and follows the front-end generation. Once they are separated, what gets rewritten at generation change is the rendering layer, not the thousands of structural definitions.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-a-controlled-experiment-how-many-places-for-the-same-field&quot;&gt;6. A controlled experiment: how many places for the same field&lt;/h3&gt;
&lt;p&gt;Back to adding Required Date to orders. I counted it in this series' case study application, and the answer is three definition edits and zero lines of code.&lt;/p&gt;
&lt;p&gt;Table structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;DbField FieldName=&quot;required_date&quot; Caption=&quot;Required Date&quot; DbType=&quot;Date&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Form structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;FormField FieldName=&quot;required_date&quot; Caption=&quot;Required Date&quot; DbType=&quot;Date&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Layout:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;LayoutField FieldName=&quot;required_date&quot; Caption=&quot;Required Date&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What follows automatically after that: the database column, the form control, the list column, the SQL for insert, update and delete, and transport across layers.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No entity class to keep in sync, because the SQL is assembled from the definition at run time&lt;/li&gt;
&lt;li&gt;No DTO or mapper to keep in sync, because the data crossing layers carries its own structural description&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The count moves by one in either direction depending on the situation, but the count is not the point. The point is that all three places are definition data.&lt;/p&gt;
&lt;h4 id=&quot;what-happened-to-the-team-boundary&quot;&gt;What happened to the team boundary&lt;/h4&gt;
&lt;p&gt;The front end no longer declares the field a second time. It asks the back end for that definition at run time and renders accordingly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;front end starts -&amp;gt; asks the back end for the definition -&amp;gt; grows controls, list columns and validation from it
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The contract does not have to be negotiated, because the contract is that definition. There is no second copy that could disagree with it.&lt;/li&gt;
&lt;li&gt;There is nothing to wait for. The back end changes the definition, and the front end gets the new one the next time it asks.&lt;/li&gt;
&lt;li&gt;The coordination, scheduling, sequencing and release windows from the first section mostly just disappear.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What is left is half of the first step: what this field is called in business terms, where it goes, and whether it is required. Somebody was always going to decide that. It is a business judgment, not communication overhead.&lt;/p&gt;
&lt;p&gt;The boundary has not vanished, though. It moved.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The front end still needs a rendering layer that knows how to read definitions, and somebody has to build it. The difference is that it gets built once, and after that every form and every field is free.&lt;/li&gt;
&lt;li&gt;The team boundary drops from &quot;every field&quot; down to &quot;the rendering layer,&quot; and the frequency of conversation changes from &quot;every request&quot; to &quot;whenever framework capability changes.&quot;&lt;/li&gt;
&lt;li&gt;Anything that genuinely cannot be declared as a definition still has to be discussed. A rule about when a document may not be shipped still needs someone to decide who blocks it, at which layer, and what the message says. No architecture escapes that. What definition-driven design can eliminate is structural restatement, not semantic negotiation.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;after-the-change-lands&quot;&gt;After the change lands&lt;/h4&gt;
&lt;p&gt;Two practical problems remain, and the framework has to absorb both:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The physical database does not have that column yet. The framework compares the definition against the actual structure and adds the column, so nobody hand writes a migration. A later post takes this apart.&lt;/li&gt;
&lt;li&gt;The system is running, so how does it know the definition changed? The node that edited the definition has to be able to notify the others so their cached copies are invalidated. &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@jeff377/day-12-cross-node-invalidation-for-definition-and-data-caches-dbef9c7f4b82&quot;&gt;Cross-Node Invalidation for Definition and Data Caches&lt;/a&gt; covers that.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The point is one sentence: between changing a definition and having it take effect there is no compilation, no packaging and no assembly deployment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-a-map-of-the-thirty-posts&quot;&gt;7. A map of the thirty posts&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Chapter &lt;/th&gt;&lt;th&gt;Theme&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;One (posts 1 to 3) &lt;/td&gt;&lt;td&gt;Why Definition-Driven&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two (posts 4 to 8) &lt;/td&gt;&lt;td&gt;Definition Layer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Three (posts 9 to 12) &lt;/td&gt;&lt;td&gt;Data Access and Caching&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Four (posts 13 to 18) &lt;/td&gt;&lt;td&gt;Business Logic and API&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Five (posts 19 to 21) &lt;/td&gt;&lt;td&gt;Multi-Tenancy and Customization&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Six (posts 22 to 25) &lt;/td&gt;&lt;td&gt;Transport, Security and Audit&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seven (posts 26 to 28) &lt;/td&gt;&lt;td&gt;Data Semantics&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Eight (posts 29 to 30) &lt;/td&gt;&lt;td&gt;Case Review and The Big Picture&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every post is written roughly the same way. First what the problem requires you to weigh, then how the framework is designed and why it was designed that way, and where a case study can verify it, a closing section with the actual Northwind definitions.&lt;/p&gt;
&lt;p&gt;Two things to be clear about up front:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The subject is long term cost, not development speed. In the short term Code-First wins almost every time: mature tooling, a complete ecosystem, a fast start. The cost of definition-driven design is concentrated at the beginning, and the return does not show until year three or year five. If your system ships, closes, and gets rewritten wholesale three years later, this series is of limited value to you. It is written for people who know they will be maintaining something for a long time.&lt;/li&gt;
&lt;li&gt;Trade-offs and costs will be written as plainly as benefits. The value of a framework is not in what it claims to do. It is in knowing precisely what it does not do, and why. Highly dynamic UIs, dashboards, reports and anything that is not form-shaped will only hurt more if you force them into this model.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-thread-to-pick-up-later&quot;&gt;A thread to pick up later&lt;/h3&gt;
&lt;p&gt;The next post, &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi&quot;&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;, is about which decisions a framework should make on its users' behalf. The one after that, &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33&quot;&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, brings the case study on stage.&lt;/p&gt;
&lt;p&gt;The case study builds a complete application out of Northwind: eight forms, master-detail, three cross-table relationships, validation, all of it. Exactly one of those forms needs code written for it. What is inside that one, and which places we deliberately chose not to express as definitions, gets reconciled item by item in &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@jeff377/day-29-which-part-definitions-the-framework-and-application-code-each-own-6d3269aa8cf5&quot;&gt;Which Part Definitions, the Framework and Application Code Each Own&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The enemy of ERP is not complexity. It is restatement. And the reason tooling cannot eliminate restatement is that restatement is a product of the paradigm. As long as code is the source, the same fact is condemned to be said many times over.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;📚 Part of the thirty-post series &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/33029/erp-architects-notes-designing-a-definition-driven-framework-series-index&quot;&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377/day-01-why-definition-driven&quot;&gt;https://hackmd.io/@jeff377/day-01-why-definition-driven&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/profile.php?id=61574839666569&quot;&gt;Facebook&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377&quot;&gt;HackMD&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377&quot;&gt;GitHub&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/profiles/jeff377&quot;&gt;NuGet&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33030/day-1-why-erp-needs-a-definition-driven-framework</guid>
<pubDate>Fri, 30 Oct 2026 01:23:02 +0000</pubDate>
</item>
<item>
<title>A JSON-RPC Library With No Method Registration: How Does It Decide What Can Be Called?</title>
<link>https://coderlegion.com/32964/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called</link>
<description>&lt;p&gt;JSON-RPC 2.0 is a small specification: the client sends &lt;code&gt;{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;method&quot;:&quot;...&quot;,&quot;params&quot;:{...},&quot;id&quot;:1}&lt;/code&gt;, and the server answers with a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. REST makes you decide on routes, HTTP verbs and status codes; JSON-RPC only cares about which method to call and with what parameters. For an API where you own both the front end and the back end, that is pleasant to work with.&lt;/p&gt;
&lt;p&gt;Polhem.JsonRpc is a set of .NET packages for JSON-RPC 2.0: a transport-independent server, an ASP.NET Core endpoint and a client, built on System.Text.Json alone. It started as the API layer of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem&quot;&gt;Polhem framework&lt;/a&gt;. I have now extracted it into standalone packages that do not depend on the framework, so other projects can reference them directly. The source is on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc&quot;&gt;GitHub&lt;/a&gt; under the MIT license, and the packages are on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages?q=Polhem.JsonRpc&quot;&gt;NuGet&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-what-it-covers&quot;&gt;1️⃣ What it covers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Full JSON-RPC 2.0: ordinary calls, notifications (calls that expect no response), and batches (several calls in one message). An id can be a string, an integer or &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A transport-independent server: the dispatcher (the part that finds and runs the method) knows nothing about HTTP. The ASP.NET Core endpoint is a separate package.&lt;/li&gt;
&lt;li&gt;A client: an HTTP transport, batches, notifications, and interceptors that can rewrite the parameters and result of each call.&lt;/li&gt;
&lt;li&gt;Optional payload encryption: the parameters and result go into an envelope that can be compressed and encrypted, and replayed calls can be rejected.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is split into several NuGet packages, so an application references only what it needs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Package &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc&quot;&gt;Polhem.JsonRpc&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Message types, error codes and the transport abstraction shared by the server and the client&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Server&quot;&gt;Polhem.JsonRpc.Server&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The dispatcher: method resolution, parameter binding and filters&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.AspNetCore&quot;&gt;Polhem.JsonRpc.AspNetCore&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The ASP.NET Core endpoint, &lt;code&gt;MapJsonRpc&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Client&quot;&gt;Polhem.JsonRpc.Client&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The client, &lt;code&gt;JsonRpcConnector&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload&quot;&gt;Polhem.JsonRpc.Payload&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: the payload envelope, compression, encryption and replay protection&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Client&quot;&gt;Polhem.JsonRpc.Payload.Client&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: a client that seals the parameters and opens the result of every call&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Server&quot;&gt;Polhem.JsonRpc.Payload.Server&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: opens the envelope and checks for replays on the server&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The server references &lt;code&gt;Polhem.JsonRpc.AspNetCore&lt;/code&gt;, and the client references only &lt;code&gt;Polhem.JsonRpc.Client&lt;/code&gt;. The split keeps a client, whether a desktop app or a mobile app, from pulling in ASP.NET Core and the reflection-based dispatcher.&lt;/p&gt;
&lt;p&gt;Native AOT compiles an app to native code at publish time, so nothing can rely on reflection at run time; iOS and WebAssembly both need it. The shared package, the client and the payload packages are marked AOT-compatible, and CI publishes a test program with Native AOT and runs it. The server package finds methods by reflection, so it does not support AOT. The target framework is &lt;code&gt;net10.0&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-getting-started&quot;&gt;2️⃣ Getting started&lt;/h3&gt;
&lt;h4 id=&quot;asp-net-core-server-minimal-api-&quot;&gt;ASP.NET Core server (Minimal API)&lt;/h4&gt;
&lt;p&gt;The server uses ASP.NET Core Minimal APIs (endpoints mapped directly in &lt;code&gt;Program.cs&lt;/code&gt; with &lt;code&gt;MapXxx&lt;/code&gt;, without MVC controllers). &lt;code&gt;MapJsonRpc&lt;/code&gt; creates a POST endpoint at the given path, and every JSON-RPC call comes in through it. &lt;code&gt;AddControllers&lt;/code&gt; is not needed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; needs three things: register the object factory, register the JSON-RPC services, and map the endpoint.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using Polhem.JsonRpc.AspNetCore;
using Polhem.JsonRpc.Server;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton&amp;lt;IJsonRpcObjectFactory, AppObjectFactory&amp;gt;();
builder.Services.AddJsonRpcServer();

var app = builder.Build();
app.MapJsonRpc(&quot;/api&quot;);
await app.RunAsync();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AddJsonRpcServer&lt;/code&gt; takes two sets of options. The first configures the server itself, such as filters, the method policy, the batch limit and serializer options. The second configures the HTTP endpoint, such as the request body size limit and which HTTP status code an error returns. Leave them out and the defaults apply.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddJsonRpcServer(
    options =&amp;gt;
    {
        options.MaxBatchSize = 50;
        options.Filters.Add(new ApiKeyFilter(apiKey)); // see section 5
    },
    http =&amp;gt; http.MaxRequestBodySize = 1024 * 1024);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MapJsonRpc&lt;/code&gt; returns the same thing as any other Minimal API endpoint, so authorization, CORS and rate limiting chain on the usual way, for example &lt;code&gt;app.MapJsonRpc(&quot;/api&quot;).RequireAuthorization();&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The object factory and the class that handles the calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class AppObjectFactory : IJsonRpcObjectFactory
{
    public object? CreateObject(string progId, JsonRpcRequestContext context) =&amp;gt; progId switch
    {
        &quot;Calculator&quot; =&amp;gt; new Calculator(),
        _ =&amp;gt; null,
    };
}

public sealed class Calculator
{
    public AddResponse Add(AddRequest request) =&amp;gt; new() { Sum = request.A + request.B };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;client&quot;&gt;Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var http = new HttpClient { BaseAddress = new Uri(&quot;http://localhost:5080/api&quot;) };
var rpc = new JsonRpcConnector(new HttpTransport(http));

var added = await rpc.InvokeAsync&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 1, B = 2 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is enough to call &lt;code&gt;Calculator.Add&lt;/code&gt;. Note that the server has no &lt;code&gt;AddMethod&lt;/code&gt; or similar registration, and &lt;code&gt;Calculator&lt;/code&gt; carries no attribute.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-with-no-registration-which-methods-can-be-called-&quot;&gt;3️⃣ With no registration, which methods can be called?&lt;/h3&gt;
&lt;p&gt;Skipping registration is convenient, but it raises a worry: if one day I add a public method to the class for internal use, can it be called from the network too? When the server receives &lt;code&gt;Calculator.Add&lt;/code&gt;, it runs the following checks in order.&lt;/p&gt;
&lt;p&gt;Name format. The method name must be &lt;code&gt;ProgId.Action&lt;/code&gt;. A ProgId (program identifier) may contain letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. A malformed name is rejected before any lookup.&lt;/p&gt;
&lt;p&gt;The application decides the object. The ProgId is passed to your implementation of &lt;code&gt;IJsonRpcObjectFactory.CreateObject&lt;/code&gt;, which decides which object to create; returning &lt;code&gt;null&lt;/code&gt; means the ProgId is unknown. The packages keep no registry of names, so which ProgIds exist is entirely up to the factory. After the call, the object is passed to &lt;code&gt;ReleaseObjectAsync&lt;/code&gt; to be released.&lt;/p&gt;
&lt;p&gt;Method signature. An action maps only to a public, non-generic instance method with exactly one parameter. Static methods, property accessors and methods declared by &lt;code&gt;object&lt;/code&gt; do not count. If two or more methods with the same name qualify, the server does not pick one; it treats the method as not found.&lt;/p&gt;
&lt;p&gt;Naming convention. The parameter type must be named &lt;code&gt;{Action}Request&lt;/code&gt;, and the return type (or the result type of &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt; or &lt;code&gt;ValueTask&amp;lt;T&amp;gt;&lt;/code&gt;) must be named &lt;code&gt;{Action}Response&lt;/code&gt;. &lt;code&gt;Add&lt;/code&gt; takes an &lt;code&gt;AddRequest&lt;/code&gt; and returns an &lt;code&gt;AddResponse&lt;/code&gt;. This is the default method policy; any public method that does not follow it is treated as nonexistent and answered with &lt;code&gt;-32601 Method not found&lt;/code&gt;. An internal helper method that does not follow this naming cannot be called from outside.&lt;/p&gt;
&lt;p&gt;The rule can be replaced through &lt;code&gt;JsonRpcServerOptions.MethodPolicy&lt;/code&gt;. The Polhem framework replaces it with one that reads its own access-control attribute.&lt;/p&gt;
&lt;p&gt;Policy before filters. The method policy runs before every filter, so filters such as authorization checks or decryption only ever see calls that are allowed to run.&lt;/p&gt;
&lt;p&gt;Only after these checks are the &lt;code&gt;params&lt;/code&gt; bound. &lt;code&gt;params&lt;/code&gt; must be a JSON object, deserialized into the request class with camelCase names by default; missing &lt;code&gt;params&lt;/code&gt; or an array (positional parameters) are answered with &lt;code&gt;-32602 Invalid params&lt;/code&gt;. To return a custom error, a method throws &lt;code&gt;JsonRpcErrorException&lt;/code&gt;; any other exception is answered with &lt;code&gt;-32603 Internal error&lt;/code&gt;, and the exception message is not sent to the client.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-client&quot;&gt;4️⃣ The client&lt;/h3&gt;
&lt;p&gt;An error returned by the server becomes a &lt;code&gt;JsonRpcErrorException&lt;/code&gt;, which carries the error code and message:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;try
{
    await rpc.InvokeAsync&amp;lt;DivideResponse&amp;gt;(&quot;Calculator.Divide&quot;, new DivideRequest { Dividend = 1, Divisor = 0 });
}
catch (JsonRpcErrorException ex)
{
    Console.WriteLine($&quot;Error {ex.Code}: {ex.Message}&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notifications and batches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// The server runs it but does not respond.
await rpc.NotifyAsync(&quot;Calculator.Log&quot;, new LogRequest { Message = &quot;Hello&quot; });

// Two calls sent in the same message.
var batch = rpc.CreateBatch();
var first = batch.Add&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 2, B = 3 });
var second = batch.Add&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 4, B = 5 });
await batch.SendAsync();
Console.WriteLine($&quot;{(await first)!.Sum}, {(await second)!.Sum}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The request and response classes usually live in a project shared by both sides, which is how the samples in the repo are arranged.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-extension-points-filters-and-interceptors&quot;&gt;5️⃣ Extension points: filters and interceptors&lt;/h3&gt;
&lt;p&gt;A server filter wraps every call. It can reject the call, rewrite the parameters before the method runs, and rewrite the result afterwards. For example, checking an API key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class ApiKeyFilter(string expectedKey) : IJsonRpcFilter
{
    public ValueTask InvokeAsync(JsonRpcRequestContext context, JsonRpcFilterDelegate next)
    {
        context.Transport.Headers.TryGetValue(&quot;X-Api-Key&quot;, out var key);
        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(key ?? &quot;&quot;), Encoding.UTF8.GetBytes(expectedKey)))
        {
            throw new JsonRpcErrorException(-32001, &quot;Unauthorized&quot;);
        }
        return next(context);
    }
}

builder.Services.AddJsonRpcServer(options =&amp;gt; options.Filters.Add(new ApiKeyFilter(apiKey)));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To add an HTTP header on the client, use &lt;code&gt;HttpClient&lt;/code&gt;'s own &lt;code&gt;DelegatingHandler&lt;/code&gt;; the packages add no layer of their own. To rewrite the parameters and result of every call, add an &lt;code&gt;IJsonRpcClientInterceptor&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The Polhem framework plugs its sessions and authorization checks into these extension points (the object factory, the method policy and filters). None of that logic is in the packages themselves.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-encrypted-payloads&quot;&gt;6️⃣ Encrypted payloads&lt;/h3&gt;
&lt;p&gt;HTTPS protects the connection. If the parameters and results also need end-to-end protection, or a call must not run a second time when it is resent, add the optional payload packages.&lt;/p&gt;
&lt;p&gt;They put &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; into an envelope with three formats: plain JSON, encoded (serialized, then gzip-compressed), and encrypted (AES-256-CBC with HMAC-SHA256, encrypt-then-MAC). A frame carrying a timestamp and a sequence number can also be put in front of the content, so the server can reject expired or repeated requests; that is the replay protection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Server: the application's policy decides the keys and which methods must be encrypted or replay-protected.
builder.Services.AddJsonRpcServer(options =&amp;gt; options.UsePayload(payloadOptions, new MyPayloadPolicy()));

// Client: called the same way as an ordinary call; each call seals its parameters and opens its result.
var rpc = new PayloadConnector(connector, new PayloadProcessor(payloadOptions),
    new PayloadConnectorOptions { KeyProvider = () =&amp;gt; sessionKey });
var added = await rpc.InvokeAsync&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, request);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few more security details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The HMAC is compared in constant time and verified before anything is decrypted.&lt;/li&gt;
&lt;li&gt;The HMAC covers the method name and the direction, so an intercepted call cannot be redirected to another method, and a response cannot be sent back as a request.&lt;/li&gt;
&lt;li&gt;A method can require a minimum format; a request in a lower format is rejected before any key is asked for.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How keys are negotiated (exchanged after sign-in, provisioned in advance, and so on) is outside the packages and left to the application.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-how-it-differs-from-common-libraries&quot;&gt;7️⃣ How it differs from common libraries&lt;/h3&gt;
&lt;p&gt;There are quite a few JSON-RPC libraries for .NET; here are two common ones for comparison. The version in each column header is the latest stable release on NuGet as of October 2026, and the details come from that release's documentation and NuGet dependency list. Later releases may differ.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;  &lt;/th&gt;&lt;th&gt;StreamJsonRpc 2.25.29 &lt;/th&gt;&lt;th&gt;Tochka.JsonRpc 7.4.1 &lt;/th&gt;&lt;th&gt;Polhem.JsonRpc 1.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;JSON library &lt;/td&gt;&lt;td&gt;Newtonsoft.Json by default, switchable to System.Text.Json; the package still depends on Newtonsoft.Json and MessagePack &lt;/td&gt;&lt;td&gt;System.Text.Json &lt;/td&gt;&lt;td&gt;System.Text.Json, with no other dependencies&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Transport model &lt;/td&gt;&lt;td&gt;Full duplex over a Stream, WebSocket or Pipe &lt;/td&gt;&lt;td&gt;HTTP on ASP.NET Core MVC &lt;/td&gt;&lt;td&gt;HTTP request and response on ASP.NET Core Minimal APIs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Client &lt;/td&gt;&lt;td&gt;Yes, with proxies generated from interfaces &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How methods are registered &lt;/td&gt;&lt;td&gt;&lt;code&gt;AddLocalRpcTarget&lt;/code&gt; adds a target object whose public methods become callable &lt;/td&gt;&lt;td&gt;Controllers that inherit &lt;code&gt;JsonRpcControllerBase&lt;/code&gt;; each action is a method &lt;/td&gt;&lt;td&gt;No registration; found by &lt;code&gt;ProgId.Action&lt;/code&gt; and the naming convention&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Native AOT &lt;/td&gt;&lt;td&gt;Partially supported, within the limits its documentation lists &lt;/td&gt;&lt;td&gt;Tied to MVC, not supported &lt;/td&gt;&lt;td&gt;The shared package, the client and the payload packages support it; the server does not&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Good fit &lt;/td&gt;&lt;td&gt;Two-way communication, such as Visual Studio extensions or LSP &lt;/td&gt;&lt;td&gt;You already use MVC and want to keep writing controllers &lt;/td&gt;&lt;td&gt;An API where you own both ends, possibly with desktop or mobile clients&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;StreamJsonRpc is the most complete, with cancellation, progress reporting and server-to-client calls, but it is designed for streams and full duplex; turning it into an HTTP request/response API means building the endpoint yourself.&lt;/p&gt;
&lt;p&gt;Tochka.JsonRpc is the closest in scope to Polhem.JsonRpc. It builds on MVC controllers, so MVC filters and model binding work as they are, at the cost of sharing MVC's lack of AOT support.&lt;/p&gt;
&lt;p&gt;Polhem.JsonRpc goes in a different direction: it trades a fixed call shape for &quot;no registration&quot; and &quot;an API that can evolve&quot;, as the next section explains.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-design-trade-offs&quot;&gt;8️⃣ Design trade-offs&lt;/h3&gt;
&lt;p&gt;The fixed call shape is there so the API can evolve. The &lt;code&gt;ProgId.Action&lt;/code&gt; name, one request class and one response class are a deliberate restriction. When a member is added to a request or response class, a member an older front end does not send binds to its default on the server, and a result member an older front end does not know is simply skipped when read. Old and new front ends call the same method, with no &lt;code&gt;v1&lt;/code&gt; and &lt;code&gt;v2&lt;/code&gt; endpoints to maintain. Positional parameters cannot do this: add, remove or reorder one parameter and every caller has to change.&lt;/p&gt;
&lt;p&gt;Defaults follow the specification. The internal error code is &lt;code&gt;-32603&lt;/code&gt;, a response carries only &lt;code&gt;jsonrpc&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt; is always written, as &lt;code&gt;null&lt;/code&gt; when it cannot be determined. The Polhem framework's original response format differed from the specification in places; when the packages were extracted, it was the framework that changed to follow the specification, not the packages that bent to the old format.&lt;/p&gt;
&lt;p&gt;The reasoning behind each decision is recorded as ADRs (Architecture Decision Records) in the repo's &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/tree/main/maintainers/adr&quot;&gt;&lt;code&gt;maintainers/adr/&lt;/code&gt;&lt;/a&gt;, and the security design is summarized in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/blob/main/docs/security.md&quot;&gt;&lt;code&gt;docs/security.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-wrapping-up&quot;&gt;✅ Wrapping up&lt;/h3&gt;
&lt;p&gt;Install the server and client packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The repo includes &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/tree/main/samples&quot;&gt;samples&lt;/a&gt;: an ASP.NET Core Minimal API server, a console client (ordinary calls, error handling, notifications and batches), and a server and client using encrypted payloads.&lt;/p&gt;
&lt;p&gt;These packages do convention-based JSON-RPC, not general-purpose JSON-RPC. Protocols whose method names and parameters are defined by someone else, such as the Language Server Protocol, MCP or an Ethereum node's API (names like &lt;code&gt;textDocument/didOpen&lt;/code&gt; and &lt;code&gt;eth_call&lt;/code&gt; with positional parameters), cannot be implemented with them; StreamJsonRpc or the MCP C# SDK fit those better. Nor do they offer a full-duplex connection where the server calls the client: one call is one HTTP request and its response.&lt;/p&gt;
&lt;p&gt;If you own both ends of your API, want JSON-RPC that relies on System.Text.Json alone, or have clients that run as desktop or mobile apps, give it a try. Feedback, issues and pull requests are welcome.&lt;/p&gt;
&lt;h3 id=&quot;further-reading&quot;&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.jsonrpc.org/specification&quot;&gt;JSON-RPC 2.0 Specification&lt;/a&gt;: the full JSON-RPC 2.0 specification&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/vs-streamjsonrpc&quot;&gt;StreamJsonRpc&lt;/a&gt;: the choice when you need full-duplex streams&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tochka-public/Tochka.JsonRpc&quot;&gt;Tochka.JsonRpc&lt;/a&gt;: JSON-RPC built on ASP.NET Core MVC controllers&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/dotnet/core/deploying/native-aot/&quot;&gt;Native AOT deployment&lt;/a&gt;: the limits and compatibility of .NET Native AOT&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377/polhem-jsonrpc&quot;&gt;https://hackmd.io/@jeff377/polhem-jsonrpc&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/profile.php?id=61574839666569&quot;&gt;Facebook&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377&quot;&gt;HackMD&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377&quot;&gt;GitHub&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/profiles/jeff377&quot;&gt;NuGet&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32964/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called</guid>
<pubDate>Thu, 29 Oct 2026 16:15:05 +0000</pubDate>
</item>
<item>
<title>A Stopwatch Needs an Isolated Lane</title>
<link>https://coderlegion.com/32820/a-stopwatch-needs-an-isolated-lane</link>
<description>&lt;p&gt;Timing tests look wonderfully objective. Start a stopwatch, call the component, stop the watch, and compare the result with a budget.&lt;/p&gt;
&lt;p&gt;The number is precise. The experiment may not be.&lt;/p&gt;
&lt;p&gt;That distinction matters in continuous integration, where many tests share CPU time, thread pools, network stacks, containers, and database capacity. A wall-clock assertion can fail because the product became slower. It can also fail because the test runner became busier. If we cannot distinguish those two explanations, the test is producing urgency without producing useful evidence.&lt;/p&gt;
&lt;h3 id=&quot;the-contract-was-reasonable&quot;&gt;The contract was reasonable&lt;/h3&gt;
&lt;p&gt;Consider a service with separate liveness and readiness checks. Liveness says the process is still running. Readiness says the instance can safely accept work.&lt;/p&gt;
&lt;p&gt;One integration test deliberately points the service at an unreachable dependency. The expected behavior is not success. The service should return an unavailable response, and it should do so within a fixed upper bound. That is a meaningful reliability contract: an orchestrator needs a quick, honest answer rather than a request that hangs behind retry policies intended for normal user operations.&lt;/p&gt;
&lt;p&gt;The test boots the real host, makes the request, verifies the unavailable result, and checks elapsed time.&lt;/p&gt;
&lt;p&gt;So far, so good.&lt;/p&gt;
&lt;h3 id=&quot;the-failure-blamed-the-wrong-interval&quot;&gt;The failure blamed the wrong interval&lt;/h3&gt;
&lt;p&gt;The timing assertion began failing repeatedly in CI. Raising the limit would have made the build green. Adding an automatic retry might also have hidden the symptom.&lt;/p&gt;
&lt;p&gt;Neither action would have explained the result.&lt;/p&gt;
&lt;p&gt;The endpoint implementation had not changed during the relevant window. More importantly, the surrounding timing evidence showed that host startup in the same test took roughly as long as the delay attributed to the readiness request. The test also ran beside many parallel integration classes doing heavier work.&lt;/p&gt;
&lt;p&gt;The stopwatch was attached to one request, but that request still depended on threads scheduled in a shared process. Under contention, continuation work can wait. Startup can wait. Connection failure can be observed later than expected. The test reports wall-clock time, not pure execution time.&lt;/p&gt;
&lt;p&gt;This does not prove the service is fast. It proves the original experiment was unable to tell product latency from runner contention.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-budget-before-changing-it&quot;&gt;Keep the budget before changing it&lt;/h3&gt;
&lt;p&gt;When a timing test turns red, I prefer to keep the bound intact while investigating. The bound represents a real operational expectation. Relaxing it before understanding the measurement destroys evidence that may still be valuable.&lt;/p&gt;
&lt;p&gt;A useful first pass is to separate three intervals:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Setup&lt;/strong&gt; — host boot, fixture creation, migrations, container startup, and test data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contention&lt;/strong&gt; — time spent waiting for CPU, threads, sockets, database slots, or another shared resource.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Behavior&lt;/strong&gt; — the operation whose latency the assertion is meant to protect.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Instrumenting those boundaries does not require a production profiler. Even a few carefully placed timestamps can reveal that the supposedly slow operation accounts for only a small part of the elapsed test.&lt;/p&gt;
&lt;h3 id=&quot;isolate-the-claim-not-the-whole-suite&quot;&gt;Isolate the claim, not the whole suite&lt;/h3&gt;
&lt;p&gt;The narrow repair was to move only the timing proof into a non-parallel test collection.&lt;/p&gt;
&lt;p&gt;The response status did not change. The time budget did not change. The endpoint code did not change. Functional readiness tests stayed in the normal parallel suite. Only the test making a wall-clock claim received an isolated measurement lane.&lt;/p&gt;
&lt;p&gt;That scope matters. Serializing an entire integration project can turn a useful diagnosis into an expensive habit. Parallel tests are valuable because they shorten feedback cycles and expose accidental shared state. The aim is not to remove parallelism. It is to stop using an uncontrolled parallel environment as a precision instrument.&lt;/p&gt;
&lt;p&gt;I like to keep separate tests for separate questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the check return the correct status when the dependency is unavailable?&lt;/li&gt;
&lt;li&gt;Does the response avoid leaking infrastructure details?&lt;/li&gt;
&lt;li&gt;Does the host remain alive while readiness is false?&lt;/li&gt;
&lt;li&gt;Under controlled contention, does the readiness answer stay within its operational budget?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Only the last question necessarily needs timing isolation.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;An isolated collection adds a serial tail to the build. It also creates another test boundary that future maintainers must understand. If many tests demand exclusive execution, the suite can become slow and the isolation can conceal real capacity problems.&lt;/p&gt;
&lt;p&gt;That is why this technique should be applied to the measurement contract, not used as a blanket cure for flakiness.&lt;/p&gt;
&lt;p&gt;In return, failures become more actionable. A red timing check is less likely to mean “the runner happened to be crowded” and more likely to mean “the protected latency contract changed.” The team spends less time rerunning the same experiment and less time normalizing noise by widening budgets.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;Before changing a failed CI time limit, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Did the measured implementation change?&lt;/li&gt;
&lt;li&gt;Is setup included in the timed region?&lt;/li&gt;
&lt;li&gt;Which resources are shared with parallel tests?&lt;/li&gt;
&lt;li&gt;Does the test need fixtures it is currently competing for?&lt;/li&gt;
&lt;li&gt;Can functional correctness remain parallel while the timing proof runs alone?&lt;/li&gt;
&lt;li&gt;Would the same isolation exist in the production condition we care about?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last question prevents overfitting. An isolated test should remove irrelevant test-runner noise, not erase meaningful load. If the production contract is specifically about behavior under concurrency, create a controlled load test with explicit pressure instead of relying on incidental CI contention.&lt;/p&gt;
&lt;h3 id=&quot;precision-is-a-property-of-the-experiment&quot;&gt;Precision is a property of the experiment&lt;/h3&gt;
&lt;p&gt;A stopwatch does not make a test scientific by itself. The environment, boundaries, and competing work determine what the number means.&lt;/p&gt;
&lt;p&gt;Keep meaningful budgets. Investigate before retrying. Isolate the smallest timing claim that needs a controlled lane, and leave the rest of the suite parallel.&lt;/p&gt;
&lt;p&gt;The result is not merely a greener pipeline. It is a test whose failure tells you something you can act on.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32820/a-stopwatch-needs-an-isolated-lane</guid>
<pubDate>Wed, 28 Oct 2026 10:49:36 +0000</pubDate>
</item>
<item>
<title>From Anemic Models to Rich Domain Models, DDD Base Entities, and C# Records</title>
<link>https://coderlegion.com/32663/from-anemic-models-to-rich-domain-models-ddd-base-entities-and-c-records</link>
<description>&lt;p&gt;When building enterprise applications in C#, a common architectural trap is writing &lt;strong&gt;procedural code disguised as object-oriented code&lt;/strong&gt;. This usually happens when we rely entirely on anemic data bags &lt;code&gt;(&lt;/code&gt;public get; set;&lt;code&gt; DTOs) for business logic, forcing our application services to manage all validation and state changes manually.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;In this guide, we will tie together &lt;strong&gt;Object-Oriented Programming (OOP)&lt;/strong&gt;, the &lt;strong&gt;Single Responsibility Principle (SRP)&lt;/strong&gt;, &lt;strong&gt;Domain-Driven Design (DDD) Entity base classes&lt;/strong&gt;, &lt;strong&gt;C# Records for Value Objects&lt;/strong&gt;, &lt;strong&gt;Combining Interfaces &amp;amp; Abstract Classes&lt;/strong&gt;, &lt;strong&gt;EF Core Persistence&lt;/strong&gt;, and &lt;strong&gt;Unit Testing&lt;/strong&gt; into a complete, publication-ready reference.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-1-the-anti-pattern-anemic-models-amp-dto-misuse-&quot;&gt;Part 1: The Anti-Pattern (Anemic Models &amp;amp; DTO Misuse)&lt;/h3&gt;
&lt;p&gt;An anemic model treats classes as passive data structures with zero behavior. When every model exposes public setters, any part of the codebase can mutate state arbitrarily, bypassing business rules.&lt;/p&gt;
&lt;h4 id=&quot;the-problematic-approach&quot;&gt;The Problematic Approach&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// 1. The Anemic Entity (Just a data bag)
public class OrderDto
{
    public Guid Id { get; set; }
    public string CustomerEmail { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; } // Can be set to anything from anywhere!
}

// 2. The God Service handling all procedural logic
public class OrderService
{
    public void ProcessOrder(OrderDto order)
    {
        // Business logic scattered in services instead of living in the model
        if (order.TotalAmount &amp;lt;= 0) throw new Exception(&quot;Invalid amount&quot;);
        order.Status = &quot;Processed&quot;; // Direct mutation without invariant checks
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-anemic-models-fail&quot;&gt;Why Anemic Models Fail&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repeated Validation Checks:&lt;/strong&gt; Since anemic models can't check their own rules, every service or controller writes the same validation code over and over. If a developer forgets one check, a business rule breaks silently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unprotected Data (Broken States):&lt;/strong&gt; Public setters mean code can accidentally create broken data—like a negative order total or an incomplete order marked as completed—which then gets saved to the database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard-to-Fix Rules (&quot;Shotgun Surgery&quot;):&lt;/strong&gt; When business rules change, you have to hunt through 10 different files and controllers to update identical &lt;code&gt;if&lt;/code&gt; statements.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-2-ddd-foundation-entity-base-class-amp-c-records&quot;&gt;Part 2: DDD Foundation — Entity Base Class &amp;amp; C# Records&lt;/h3&gt;
&lt;p&gt;In Domain-Driven Design, &lt;strong&gt;Entities&lt;/strong&gt; are defined by their unique identity rather than their attributes, while &lt;strong&gt;Value Objects&lt;/strong&gt; (idiomatically implemented using C# &lt;strong&gt;records&lt;/strong&gt;) are defined entirely by their structural values and are strictly immutable.&lt;/p&gt;
&lt;h4 id=&quot;1-ddd-entity-base-class&quot;&gt;1. DDD Entity Base Class&lt;/h4&gt;
&lt;p&gt;Notice how &lt;code&gt;EqualityComparer&amp;lt;TId&amp;gt;&lt;/code&gt; is used below to robustly handle generic identifier comparisons across different types (&lt;code&gt;Guid&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;string&lt;/code&gt;, etc.) while ensuring unpersisted entities do not falsely equal each other:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public abstract class Entity&amp;lt;TId&amp;gt;
{
    public TId Id { get; protected set; }

    public override bool Equals(object? obj)
    {
        if (obj is not Entity&amp;lt;TId&amp;gt; other) return false;
        if (ReferenceEquals(this, other)) return true;
        if (GetType() != other.GetType()) return false;

&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;    if (EqualityComparer&amp;lt;TId&amp;gt;.Default.Equals(Id, default!) || 
        EqualityComparer&amp;lt;TId&amp;gt;.Default.Equals(other.Id, default!))
        return false;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
        return EqualityComparer&amp;lt;TId&amp;gt;.Default.Equals(Id, other.Id);
    }

&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;public override int GetHashCode() =&amp;gt; (GetType().ToString() + Id).GetHashCode();
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;public static bool operator ==(Entity&amp;lt;TId&amp;gt;? a, Entity&amp;lt;TId&amp;gt;? b) =&amp;gt; a?.Equals(b) ?? b is null;
public static bool operator !=(Entity&amp;lt;TId&amp;gt;? a, Entity&amp;lt;TId&amp;gt;? b) =&amp;gt; !(a == b);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-c-records-for-value-objects&quot;&gt;2. C# Records for Value Objects&lt;/h4&gt;
&lt;p&gt;Using &lt;code&gt;record&lt;/code&gt; gives us value-based structural equality out-of-the-box and immutability by default (&lt;code&gt;init&lt;/code&gt; setters).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public record EmailAddress
{
    public string Value { get; init; }

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains(&quot;@&quot;))
            throw new ArgumentException(&quot;Invalid email format.&quot;);
        Value = value;
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-3-combining-interfaces-abstract-classes-and-ddd-entities&quot;&gt;Part 3: Combining Interfaces, Abstract Classes, and DDD Entities&lt;/h3&gt;
&lt;p&gt;Now we structure our customer hierarchy by inheriting from our DDD &lt;code&gt;Entity&amp;lt;Guid&amp;gt;&lt;/code&gt;, implementing our &lt;code&gt;ICustomer&lt;/code&gt; interface, and using our &lt;code&gt;EmailAddress&lt;/code&gt; value object.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// 1. The Interface defines the full contract
public interface ICustomer
{
    Guid Id { get; }
    EmailAddress Email { get; }
    decimal TotalSpent { get; }
    
    decimal CalculateDiscount(decimal orderAmount);
    void RecordPurchase(decimal amount);
}

// 2. The Abstract Base Class inherits from DDD Entity and implements ICustomer
public abstract class CustomerBase : Entity&amp;lt;Guid&amp;gt;, ICustomer
{
    public EmailAddress Email { get; private set; }
    public decimal TotalSpent { get; private set; }

    protected CustomerBase(Guid id, EmailAddress email)
    {
        Id = id;
        Email = email ?? throw new ArgumentNullException(nameof(email));
        TotalSpent = 0m;
    }

    public void RecordPurchase(decimal amount)
    {
        if (amount &amp;lt;= 0) throw new ArgumentException(&quot;Purchase amount must be positive.&quot;);
        TotalSpent += amount;
    }

    public abstract decimal CalculateDiscount(decimal orderAmount);
}

// 3. Concrete Implementations
public class RegularCustomer : CustomerBase
{
    public RegularCustomer(Guid id, EmailAddress email) : base(id, email) { }

    public override decimal CalculateDiscount(decimal orderAmount) =&amp;gt; 0m;
}

public class VipCustomer : CustomerBase
{
    public decimal CreditLimit { get; private set; }

    public VipCustomer(Guid id, EmailAddress email, decimal creditLimit) : base(id, email)
    {
        if (creditLimit &amp;lt; 0) throw new ArgumentException(&quot;Credit limit cannot be negative.&quot;);
        CreditLimit = creditLimit;
    }

    public override decimal CalculateDiscount(decimal orderAmount) =&amp;gt; orderAmount * 0.10m;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-4-the-rich-domain-model-order-&quot;&gt;Part 4: The Rich Domain Model (&lt;code&gt;Order&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Our &lt;code&gt;Order&lt;/code&gt; inherits from &lt;code&gt;Entity&amp;lt;Guid&amp;gt;&lt;/code&gt;, enforces business invariants, and coordinates with &lt;code&gt;ICustomer&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Order : Entity&amp;lt;Guid&amp;gt;
{
    public ICustomer Customer { get; }
    public decimal TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }

    public Order(Guid id, ICustomer customer, decimal initialAmount)
    {
        Id = id;
        Customer = customer ?? throw new ArgumentNullException(nameof(customer));
        
        if (initialAmount &amp;lt;= 0)
            throw new ArgumentException(&quot;Order amount must be greater than zero.&quot;);

        TotalAmount = initialAmount;
        Status = OrderStatus.Draft;

        ApplyCustomerDiscount();
    }

    private void ApplyCustomerDiscount()
    {
        decimal discount = Customer.CalculateDiscount(TotalAmount);
        TotalAmount -= discount;
    }

    public void AddItem(decimal itemPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException(&quot;Cannot modify an order after it has been submitted.&quot;);

        TotalAmount += itemPrice;
    }

    public void SubmitPayment()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException(&quot;Order is already processed.&quot;);

        Status = OrderStatus.Submitted;
    }
}

public enum OrderStatus { Draft, Submitted, Completed }

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-5-orchestrating-with-srp-and-dependency-injection&quot;&gt;Part 5: Orchestrating with SRP and Dependency Injection&lt;/h3&gt;
&lt;p&gt;Our application service (&lt;code&gt;OrderProcessor&lt;/code&gt;) focuses purely on coordination (following the Single Responsibility Principle).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class OrderProcessor
{
    private readonly IOrderRepository _repository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEmailNotifier _notifier;
    private readonly ILogger&amp;lt;OrderProcessor&amp;gt; _logger;

    public OrderProcessor(
        IOrderRepository repository,
        IPaymentGateway paymentGateway,
        IEmailNotifier notifier,
        ILogger&amp;lt;OrderProcessor&amp;gt; logger)
    {
        _repository = repository;
        _paymentGateway = paymentGateway;
        _notifier = notifier;
        _logger = logger;
    }

    public async Task ProcessAsync(Guid orderId)
    {
        var order = await _repository.GetByIdAsync(orderId);
        if (order == null) throw new KeyNotFoundException(&quot;Order not found.&quot;);

        order.SubmitPayment();

        if (!await _paymentGateway.ChargeAsync(order.TotalAmount))
        {
            throw new InvalidOperationException(&quot;Payment failed.&quot;);
        }

        order.Customer.RecordPurchase(order.TotalAmount);

        await _repository.SaveAsync(order);
        await _notifier.SendConfirmationAsync(order.Customer.Email.Value);

        _logger.LogInformation(&quot;Successfully processed order {OrderId}&quot;, order.Id);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-6-persistence-with-ef-core-mapping-records-amp-tph-&quot;&gt;Part 6: Persistence with EF Core (Mapping Records &amp;amp; TPH)&lt;/h3&gt;
&lt;p&gt;Using EF Core Entity configurations to map our DDD entities, value objects (records via owned types), and inheritance hierarchy.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class AppDbContext : DbContext
{
    public DbSet&amp;lt;CustomerBase&amp;gt; Customers =&amp;gt; Set&amp;lt;CustomerBase&amp;gt;();
    public DbSet&amp;lt;Order&amp;gt; Orders =&amp;gt; Set&amp;lt;Order&amp;gt;();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configure Customer Inheritance (TPH)
      
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  modelBuilder.Entity()&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        .HasKey(c =&amp;gt; c.Id);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;    modelBuilder.Entity&amp;lt;CustomerBase&amp;gt;()
        .OwnsOne(c =&amp;gt; c.Email, email =&amp;gt;
        {
            email.Property(e =&amp;gt; e.Value).HasColumnName(&quot;Email&quot;).IsRequired();
        });

    modelBuilder.Entity&amp;lt;CustomerBase&amp;gt;()
        .HasDiscriminator&amp;lt;string&amp;gt;(&quot;CustomerType&quot;)
        .HasValue&amp;lt;RegularCustomer&amp;gt;(&quot;Regular&quot;)
        .HasValue&amp;lt;VipCustomer&amp;gt;(&quot;VIP&quot;);

    // Configure Order Entity
    modelBuilder.Entity&amp;lt;Order&amp;gt;()
        .HasKey(o =&amp;gt; o.Id);

    modelBuilder.Entity&amp;lt;Order&amp;gt;()
        .HasOne(o =&amp;gt; o.Customer)
        .WithMany()
        .HasForeignKey(&quot;CustomerId&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-7-unit-testing-with-records-amp-entities&quot;&gt;Part 7: Unit Testing with Records &amp;amp; Entities&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class DomainTests
{
    [Fact]
    public void VipCustomer_WithValidData_CalculatesDiscountAndTracksSpend()
    {
        var email = new EmailAddress(&quot;*Emails are not allowed*&quot;);
        ICustomer customer = new VipCustomer(Guid.NewGuid(), email, 5000.00m);
        var order = new Order(Guid.NewGuid(), customer, 200.00m);

        // 200 - 10% = 180
        Assert.Equal(180.00m, order.TotalAmount);

        customer.RecordPurchase(order.TotalAmount);
        Assert.Equal(180.00m, customer.TotalSpent);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;references-amp-further-reading&quot;&gt;References &amp;amp; Further Reading&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Martin Fowler (2003):&lt;/strong&gt; &lt;em&gt;AnemicDomainModel&lt;/em&gt; — &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.martinfowler.com/bliki/AnemicDomainModel.html&quot;&gt;Read article&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Eric Evans (2003):&lt;/strong&gt; &lt;em&gt;Domain-Driven Design: Tackling Complexity in the Heart of Software&lt;/em&gt; — Addison-Wesley Professional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Robert C. Martin (2017):&lt;/strong&gt; &lt;em&gt;Clean Architecture: A Craftsman's Guide to Software Structure and Design&lt;/em&gt; — Prentice Hall.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32663/from-anemic-models-to-rich-domain-models-ddd-base-entities-and-c-records</guid>
<pubDate>Tue, 27 Oct 2026 10:51:17 +0000</pubDate>
</item>
<item>
<title>Tuesday Coding Tip 28 — (de)serializing JSON string enum in C#</title>
<link>https://coderlegion.com/29763/tuesday-coding-tip-28-de-serializing-json-string-enum-in-c</link>
<description>&lt;p&gt;Tuesday coding tips are super short posts about various tidbits mainly from C++, but also from other programming languages I use. You can also follow the #TuesdayCodingTips hashtag on Mastodon and Linkedin.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Last time I covered C# JSON serialization, I only showcased the &lt;code&gt;Newtonsoft.Json&lt;/code&gt; library. This time, I will also show the &lt;code&gt;System.Text.Json&lt;/code&gt; way.&lt;/p&gt;
&lt;p&gt;You might want to (de)serialize enums as strings, rather than numeric values. Since C# does not support enums with an underlying type of string, you need to do some extra work. Namely, you have to annotate each value using the &lt;code&gt;EnumMember&lt;/code&gt; annotation and then select the correct converter, once again using annotations.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

public enum Mood
{
    [EnumMember(Value = &quot;happy&quot;)]
    Happy,
    [EnumMember(Value = &quot;sad&quot;)]
    Sad,
}

public class Object
{
    [JsonProperty(&quot;mood&quot;)]

    [JsonConverter(typeof(StringEnumConverter))]
    public Mood Mood { get; set; }
}

public static class Program
{
    public static void Main()
    {
        string json = JsonConvert.SerializeObject(new Object
        {
            Mood = Mood.Sad
        });

        Console.WriteLine(json);
        // {&quot;mood&quot;:&quot;sad&quot;}
        Console.WriteLine(JsonConvert.DeserializeObject&amp;lt;Object&amp;gt;(json)!.Mood);
        // Sad
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, &lt;code&gt;System.Text.Json&lt;/code&gt; doesn’t support &lt;code&gt;EnumMember&lt;/code&gt; annotations out of the box. It takes the literal name of the property instead. You can play with capitalization using options object, but that’s it. Or you can write your own converter. Or you can just use one of the dozen converters from the Internet that do the hard work for you.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using System.Runtime.Serialization;
using System.Text.Json.Serialization;

public enum Mood
{
    [EnumMember(Value = &quot;happy&quot;)]
    Happy,
    [EnumMember(Value = &quot;sad&quot;)]
    Sad,
}

public class Object
{
    [JsonPropertyName(&quot;mood&quot;)]
    // To use EnumMember annotations, install Macross.Json.Extensions nuget
    // and use a different converter
    // [JsonConverter(typeof(JsonStringEnumMemberConverter))]
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public Mood Mood { get; set; }
}

public static class Program
{
    public static void Main()
    {
        Object obj = new()
        {
            Mood = Mood.Sad,
        };

        string json = System.Text.Json.JsonSerializer.Serialize(obj);
        Console.WriteLine(json);
        // {&quot;mood&quot;:&quot;Sad&quot;}
        Console.WriteLine(System.Text.Json.JsonSerializer.Deserialize&amp;lt;Object&amp;gt;(json)!.Mood);
        // Sad
    }
}
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29763/tuesday-coding-tip-28-de-serializing-json-string-enum-in-c</guid>
<pubDate>Tue, 27 Oct 2026 09:59:00 +0000</pubDate>
</item>
<item>
<title>A Query Window Is Not a Calendar Day</title>
<link>https://coderlegion.com/32592/a-query-window-is-not-a-calendar-day</link>
<description>&lt;p&gt;Software often uses broad time windows for good reasons. A dashboard may need recent history, work that is actionable now, and the next scheduled item. Fetching all of that in one bounded query can be efficient and easy to reason about.&lt;/p&gt;
&lt;p&gt;The trouble begins when the retrieval window quietly becomes the business definition of “today.”&lt;/p&gt;
&lt;p&gt;This is a small distinction with a very visible failure mode: shortly after midnight, yesterday’s missed item appears under today’s heading. The query is returning exactly what it was asked to return. The selection rule is wrong.&lt;/p&gt;
&lt;h3 id=&quot;retrieval-and-meaning-are-different-jobs&quot;&gt;Retrieval and meaning are different jobs&lt;/h3&gt;
&lt;p&gt;Imagine a “Today” screen that loads items from several hours in the past through the next day. The look-back allows the screen to include work that recently closed or may still be open. The look-ahead allows it to show what comes next.&lt;/p&gt;
&lt;p&gt;That range is a retrieval concern. It answers:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which records could be relevant enough to inspect?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It does not answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which record belongs to the user’s current calendar day?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If selection simply chooses the first item whose state is due or missed, a previous day’s missed item can win after midnight. If display text assumes every future item is later today, tomorrow morning can be described as though it were later tonight.&lt;/p&gt;
&lt;p&gt;Both errors come from using temporal proximity as a substitute for calendar meaning.&lt;/p&gt;
&lt;h3 id=&quot;define-today-in-the-relevant-time-zone&quot;&gt;Define “today” in the relevant time zone&lt;/h3&gt;
&lt;p&gt;Server time is rarely the correct authority for a human-facing day. UTC is excellent for storing instants and comparing elapsed time. It is not a user’s calendar.&lt;/p&gt;
&lt;p&gt;The safer sequence is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the current instant from an injected clock.&lt;/li&gt;
&lt;li&gt;Convert it once into the time zone that owns the workflow.&lt;/li&gt;
&lt;li&gt;Derive a local date from that value.&lt;/li&gt;
&lt;li&gt;Use that date when classifying calendar-bound states.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Computing the value once matters. It keeps every decision in one request anchored to the same instant, including the unlikely case where execution crosses midnight.&lt;/p&gt;
&lt;p&gt;The relevant time zone also needs to be explicit. It may belong to a person, site, account, or workflow. The correct choice is a domain decision, not an infrastructure default.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-exception-that-actually-matters&quot;&gt;Preserve the exception that actually matters&lt;/h3&gt;
&lt;p&gt;A tempting fix is to discard every record whose local date is not today. That is neat and wrong when an actionable window crosses midnight.&lt;/p&gt;
&lt;p&gt;Suppose a task opened before midnight and remains valid until shortly afterwards. A strict date filter would hide it at the exact moment it still needs attention.&lt;/p&gt;
&lt;p&gt;This is where explicit domain rules beat blanket filtering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;missed and completed items count as current only when their local date is today;&lt;/li&gt;
&lt;li&gt;a still-open item remains current while it is actionable, regardless of its start date;&lt;/li&gt;
&lt;li&gt;a scheduled item on another day can still appear as next, but its wording names that day.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule is slightly longer because the product meaning is slightly richer. That extra branch is not accidental complexity. It records why two superficially similar records are treated differently.&lt;/p&gt;
&lt;h3 id=&quot;make-wording-part-of-correctness&quot;&gt;Make wording part of correctness&lt;/h3&gt;
&lt;p&gt;Time bugs are not confined to sorting and filtering. Labels can encode false claims too.&lt;/p&gt;
&lt;p&gt;“Later today” is only correct if the next item is on the same local day. “Between 08:00 and 09:00” may be ambiguous when viewed the night before. Adding “tomorrow” or a concrete day makes the model visible to the user.&lt;/p&gt;
&lt;p&gt;That wording also gives tests a useful public outcome. A test can verify that the screen says tomorrow without reaching into a private helper or asserting a particular LINQ expression.&lt;/p&gt;
&lt;h3 id=&quot;test-the-boundary-as-a-sequence&quot;&gt;Test the boundary as a sequence&lt;/h3&gt;
&lt;p&gt;A single test at noon will not find this class of defect. The useful test moves a controllable clock through the state transition.&lt;/p&gt;
&lt;p&gt;One focused integration scenario can prove three behaviours:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;On the item’s own evening, the missed item is still classified as belonging to today.&lt;/li&gt;
&lt;li&gt;The next day’s scheduled item is labelled explicitly as tomorrow.&lt;/li&gt;
&lt;li&gt;After midnight, the old missed item no longer becomes the current card.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Using the real persistence provider is valuable when stored dates, generated occurrences, or query translation participate in the behaviour. The clock is the external boundary worth controlling; internal services do not need to be mocked if the public result proves the rule.&lt;/p&gt;
&lt;p&gt;Also test a window that genuinely spans midnight. Without that case, a cleanup that filters everything by date can look correct while introducing a different regression.&lt;/p&gt;
&lt;h3 id=&quot;the-engineering-trade-off&quot;&gt;The engineering trade-off&lt;/h3&gt;
&lt;p&gt;Explicit local-date classification costs a few things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;more branches than a simple state check;&lt;/li&gt;
&lt;li&gt;a time-zone conversion at the application boundary;&lt;/li&gt;
&lt;li&gt;boundary-focused tests and more deliberate copy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In return, the system becomes predictable at the moment when implicit assumptions fail most often. Retrieval can remain broad for performance and completeness. Domain selection becomes precise. User-facing language reflects the same model.&lt;/p&gt;
&lt;p&gt;The broader leadership lesson is to ask two questions whenever a time range appears in code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is this range finding candidates, or defining business membership?&lt;/li&gt;
&lt;li&gt;Whose calendar gives words such as today, tomorrow, and missed their meaning?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Keeping those answers separate prevents an implementation convenience from becoming an accidental policy.&lt;/p&gt;
&lt;h3 id=&quot;practical-checklist&quot;&gt;Practical checklist&lt;/h3&gt;
&lt;p&gt;When building a day-oriented view:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;store and compare instants consistently;&lt;/li&gt;
&lt;li&gt;identify the domain-owned time zone;&lt;/li&gt;
&lt;li&gt;derive the local date once per operation;&lt;/li&gt;
&lt;li&gt;keep retrieval windows separate from classification rules;&lt;/li&gt;
&lt;li&gt;preserve intentionally actionable cross-midnight windows;&lt;/li&gt;
&lt;li&gt;make off-day wording explicit;&lt;/li&gt;
&lt;li&gt;test before midnight, after midnight, and a spanning window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The code may still query eighteen or thirty hours. The user should experience a calendar day.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32592/a-query-window-is-not-a-calendar-day</guid>
<pubDate>Mon, 26 Oct 2026 20:12:29 +0000</pubDate>
</item>
<item>
<title>Idempotency Should Resume Partial Work, Not Hide It</title>
<link>https://coderlegion.com/32426/idempotency-should-resume-partial-work-not-hide-it</link>
<description>&lt;p&gt;Idempotency is usually introduced with a simple promise: run the operation again and it will not create a duplicate.&lt;/p&gt;
&lt;p&gt;That promise is useful, but it can become dangerous when we apply it to an entire workflow instead of the specific durable step it protects.&lt;/p&gt;
&lt;p&gt;I recently reviewed a recurring integration job with two independent writes. First, it appended a status note to an external record. Second, it moved that record into a terminal state. The note contained a unique marker, so retries could see that the note had already been written.&lt;/p&gt;
&lt;p&gt;The marker worked. The workflow did not.&lt;/p&gt;
&lt;p&gt;When the state transition failed once, the note remained. On the next sweep, the job saw the marker and skipped the record completely. A local sign of success for step one had accidentally become a global completion flag for steps one and two.&lt;/p&gt;
&lt;p&gt;That is a subtle reliability bug because the automation appears idempotent while the real process is only half finished.&lt;/p&gt;
&lt;h3 id=&quot;a-checkpoint-is-not-the-whole-journey&quot;&gt;A checkpoint is not the whole journey&lt;/h3&gt;
&lt;p&gt;Consider a generalized background job:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write a durable note with a unique operation marker.&lt;/li&gt;
&lt;li&gt;Move the remote record into a terminal workflow state.&lt;/li&gt;
&lt;li&gt;Repeat the scan for a bounded period so temporary failures can heal.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The marker proves exactly one fact: the note exists.&lt;/p&gt;
&lt;p&gt;It does not prove that the state move succeeded. It does not prove that a notification was delivered, a file was copied, or a later API call completed. Yet it is tempting to write an early return such as “marker found, therefore this item is done.”&lt;/p&gt;
&lt;p&gt;The safer model gives each durable step its own completion evidence. In this case, the marker suppresses only another note. Later sweeps still inspect the remote record and decide whether the second step is pending.&lt;/p&gt;
&lt;p&gt;This sounds like a small distinction. In production, it is the difference between duplicate prevention and recovery prevention.&lt;/p&gt;
&lt;h3 id=&quot;reconciliation-makes-partial-completion-explicit&quot;&gt;Reconciliation makes partial completion explicit&lt;/h3&gt;
&lt;p&gt;A resumable job expects to encounter intermediate states:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no note and no terminal state;&lt;/li&gt;
&lt;li&gt;note present but transition still pending;&lt;/li&gt;
&lt;li&gt;note present and record already terminal;&lt;/li&gt;
&lt;li&gt;note present but a person changed the record afterwards;&lt;/li&gt;
&lt;li&gt;remote workflow metadata that no longer matches our assumptions;&lt;/li&gt;
&lt;li&gt;a temporary failure that should be tried again;&lt;/li&gt;
&lt;li&gt;a permanent incompatibility that needs attention.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are not exotic edge cases. They are the normal state space of a multi-step integration.&lt;/p&gt;
&lt;p&gt;The repaired design runs the state decision on every bounded reconciliation sweep. If the note is missing, it writes the note once. Whether the note was just written or already existed, it then evaluates the transition separately.&lt;/p&gt;
&lt;p&gt;That separation makes the control flow honest. We can retry the unfinished part without repeating the finished part.&lt;/p&gt;
&lt;h3 id=&quot;ask-the-external-system-what-its-workflow-means&quot;&gt;Ask the external system what its workflow means&lt;/h3&gt;
&lt;p&gt;The original failure was also a reminder that external state names are rarely universal. One workflow may call its terminal state “Done”; another may use a different label. Hard-coding one friendly name works until a customized process rejects it.&lt;/p&gt;
&lt;p&gt;The stronger approach reads the record's type and the available states for that type. It prefers an explicitly configured state when that state exists. Otherwise, it can select a state by semantic category, such as completed or removed.&lt;/p&gt;
&lt;p&gt;This adds metadata reads and some branching. It also moves compatibility knowledge to the boundary where it belongs. The integration stops pretending that its vocabulary is the external system's vocabulary.&lt;/p&gt;
&lt;p&gt;That fallback must remain bounded. If no suitable category exists, the job should surface the incompatibility rather than inventing a transition.&lt;/p&gt;
&lt;h3 id=&quot;human-intent-outranks-automation&quot;&gt;Human intent outranks automation&lt;/h3&gt;
&lt;p&gt;Retrying forever is not the same as reconciling safely.&lt;/p&gt;
&lt;p&gt;Suppose the automation wrote its note yesterday, then a person deliberately reopened or reclassified the record today. A later sweep should not force the record back into the automation's preferred state.&lt;/p&gt;
&lt;p&gt;The reconciliation rule therefore needs ordering evidence. If the external state changed after our durable note, leave it alone. If ordering is unknown, prefer the conservative choice and do not overwrite. If the record is already in any terminal category, there is nothing to repair.&lt;/p&gt;
&lt;p&gt;This is an important boundary: the job owns completion of its own intent, not the external workflow itself.&lt;/p&gt;
&lt;h3 id=&quot;retry-by-failure-class&quot;&gt;Retry by failure class&lt;/h3&gt;
&lt;p&gt;Not every failure deserves the same response.&lt;/p&gt;
&lt;p&gt;A timeout or temporary service error can be retried by the next sweep. A missing permission or incompatible workflow definition will not improve through repetition. That condition should be reported, with noise controlled, while unrelated records continue.&lt;/p&gt;
&lt;p&gt;This classification keeps the job useful during partial outages. One bad record does not starve the batch, and operators are not flooded with alerts for failures that the normal reconciliation loop can heal.&lt;/p&gt;
&lt;p&gt;Caching workflow metadata for the duration of one sweep can also limit API traffic without letting old assumptions live indefinitely.&lt;/p&gt;
&lt;h3 id=&quot;test-the-half-finished-states&quot;&gt;Test the half-finished states&lt;/h3&gt;
&lt;p&gt;Happy-path tests are not enough for resumable automation. The valuable tests begin after something durable has already happened.&lt;/p&gt;
&lt;p&gt;I would want focused coverage proving that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an existing marker prevents a duplicate note but not a pending transition;&lt;/li&gt;
&lt;li&gt;a previously failed transition is attempted on a later sweep;&lt;/li&gt;
&lt;li&gt;different state names can resolve through semantic metadata;&lt;/li&gt;
&lt;li&gt;terminal records are left untouched;&lt;/li&gt;
&lt;li&gt;a newer human change is preserved;&lt;/li&gt;
&lt;li&gt;unknown ordering is handled conservatively;&lt;/li&gt;
&lt;li&gt;one record's failure does not block the others;&lt;/li&gt;
&lt;li&gt;temporary and permanent failures take different paths.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests describe the recovery contract more clearly than a mock verifying one ideal call sequence.&lt;/p&gt;
&lt;h3 id=&quot;the-practical-rule&quot;&gt;The practical rule&lt;/h3&gt;
&lt;p&gt;For every multi-step job, list the durable evidence produced by each step. Then ask what the next run should do when execution stops after any one of them.&lt;/p&gt;
&lt;p&gt;Idempotency should let a workflow resume safely. If an “already processed” check prevents the system from finishing later steps, it is not protecting reliability. It is hiding incomplete work.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32426/idempotency-should-resume-partial-work-not-hide-it</guid>
<pubDate>Sun, 25 Oct 2026 19:34:58 +0000</pubDate>
</item>
<item>
<title>Let the Router Own the Next Paint</title>
<link>https://coderlegion.com/32251/let-the-router-own-the-next-paint</link>
<description>&lt;p&gt;Navigation bugs are often judged by their final state. Did the correct page appear? Did the authorization check pass? Did the URL settle on the expected address?&lt;/p&gt;
&lt;p&gt;Those questions matter, but they can miss the frame that users actually notice.&lt;/p&gt;
&lt;p&gt;In a recent component-based web fix, the destination page was correct. The address was correct. The authorization result was correct. Yet, between the address change and the destination render, the departing page painted once more. Because the framework also applied its scroll reset during that interval, users briefly saw stale content at the top of the viewport before the new page arrived.&lt;/p&gt;
&lt;p&gt;The repair produced a useful general lesson: after an address change, the router should own the next visible paint.&lt;/p&gt;
&lt;h3 id=&quot;a-correct-destination-can-still-have-a-wrong-journey&quot;&gt;A correct destination can still have a wrong journey&lt;/h3&gt;
&lt;p&gt;An end-state test sees a simple sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;start on page A;&lt;/li&gt;
&lt;li&gt;navigate to page B;&lt;/li&gt;
&lt;li&gt;assert that page B is visible.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The browser may have seen something richer:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;page A is visible at the user’s current scroll position;&lt;/li&gt;
&lt;li&gt;the address changes to B;&lt;/li&gt;
&lt;li&gt;page A renders again under B’s address;&lt;/li&gt;
&lt;li&gt;a framework scroll reset moves that stale page to the top;&lt;/li&gt;
&lt;li&gt;page B finally renders.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps three and four may last only one frame. They are still part of the product.&lt;/p&gt;
&lt;p&gt;This is why navigation correctness is not only about eventual state. It also includes the ordered render sequence between the user action and the destination becoming authoritative.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-effect-of-an-event-receiver&quot;&gt;The hidden effect of an event receiver&lt;/h3&gt;
&lt;p&gt;The source of the extra paint was subtle. An asynchronous navigation callback was registered with the component that hosted the route tree as its receiver.&lt;/p&gt;
&lt;p&gt;In many component frameworks, completing an event callback schedules a render of the receiving component. That behaviour is normally helpful: event handlers change state, so the framework redraws the owner automatically.&lt;/p&gt;
&lt;p&gt;Navigation is different. The callback prepared the transition, but the router—not the departing component—owned the destination. When the receiver-bound callback completed, the framework rendered the old route tree once more. The router rendered the new page immediately afterward, but it was no longer responsible for the first batch after the address changed.&lt;/p&gt;
&lt;p&gt;The fix did not add a delay, loading overlay, or animation. It removed the accidental receiver. The callback still ran, but its completion no longer implied that the departing route component needed to render. The router’s destination batch became the first visible paint after the address change.&lt;/p&gt;
&lt;h3 id=&quot;measure-the-interval-not-just-the-answer&quot;&gt;Measure the interval, not just the answer&lt;/h3&gt;
&lt;p&gt;The strongest part of the repair was the test shape.&lt;/p&gt;
&lt;p&gt;Instead of sleeping and hoping to catch a flicker, the test marked the instant the address changed. It then counted every component render until the destination page entered the render tree.&lt;/p&gt;
&lt;p&gt;Before the change, that interval contained twelve render notifications in the exercised path. After the change, it contained zero.&lt;/p&gt;
&lt;p&gt;The specific number is less important than the invariant:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Nothing else paints after the address changes and before the destination appears.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That assertion is deterministic. It does not depend on monitor refresh rate, screenshot timing, or a human noticing a flash. It also fails for the causal behaviour rather than a visual approximation of the symptom.&lt;/p&gt;
&lt;h3 id=&quot;keeping-the-old-page-has-a-boundary&quot;&gt;Keeping the old page has a boundary&lt;/h3&gt;
&lt;p&gt;There is a reasonable usability goal behind retaining the current page: an established screen is often a better placeholder than a blank shell while the destination prepares.&lt;/p&gt;
&lt;p&gt;But retained content needs a strict boundary. The old page belongs to the source route and its source authorization decision. It must not start making decisions for the new address, consume state prepared for the destination, or imply that access to the destination has been granted.&lt;/p&gt;
&lt;p&gt;Security invalidation is the important exception. If the user signs out or loses a role, the retained page must stop being authoritative immediately. Smoothness must never extend stale permission.&lt;/p&gt;
&lt;p&gt;That gives us two complementary rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;retain a certified source view only until its replacement is ready;&lt;/li&gt;
&lt;li&gt;invalidate it immediately when the identity or authorization basis changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is continuity without authority leakage.&lt;/p&gt;
&lt;h3 id=&quot;test-the-negative-space&quot;&gt;Test the negative space&lt;/h3&gt;
&lt;p&gt;A good transition test suite should cover more than the successful move between two ordinary pages.&lt;/p&gt;
&lt;p&gt;Useful cases include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an allowed destination;&lt;/li&gt;
&lt;li&gt;a refused destination;&lt;/li&gt;
&lt;li&gt;a layout or shell change;&lt;/li&gt;
&lt;li&gt;sign-out during preparation;&lt;/li&gt;
&lt;li&gt;role loss during preparation;&lt;/li&gt;
&lt;li&gt;an older asynchronous result completing after a newer transition;&lt;/li&gt;
&lt;li&gt;a callback that finishes on a different scheduling context.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These cases reveal who truly owns the transition. If several components can repaint or decide access for the same interval, the design is probably carrying more than one authority.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-method&quot;&gt;A practical review method&lt;/h3&gt;
&lt;p&gt;When reviewing client-side navigation, draw a short timeline: user action, address change, preparation, first destination render, and interaction-ready state.&lt;/p&gt;
&lt;p&gt;For each gap, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which component may render?&lt;/li&gt;
&lt;li&gt;Which route does that render belong to?&lt;/li&gt;
&lt;li&gt;Which authorization decision supports it?&lt;/li&gt;
&lt;li&gt;Can an event callback schedule an incidental redraw?&lt;/li&gt;
&lt;li&gt;What happens if identity changes or an older task completes late?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then write at least one sequence assertion. Final-state tests remain valuable, but they should be paired with a guard over the transition when a single wrong frame matters.&lt;/p&gt;
&lt;h3 id=&quot;the-leadership-lesson&quot;&gt;The leadership lesson&lt;/h3&gt;
&lt;p&gt;Small visual defects often expose unclear ownership rather than missing polish. A loading spinner might hide the flash, but it would not explain why the departing component could repaint under the destination address.&lt;/p&gt;
&lt;p&gt;The more durable repair is to assign one owner to the critical interval and test that ownership directly.&lt;/p&gt;
&lt;p&gt;Users experience every frame, not only the last one. Our tests should be willing to do the same.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32251/let-the-router-own-the-next-paint</guid>
<pubDate>Sat, 24 Oct 2026 07:06:52 +0000</pubDate>
</item>
<item>
<title>Tolerant Decoding, Strict Release Gates</title>
<link>https://coderlegion.com/32154/tolerant-decoding-strict-release-gates</link>
<description>&lt;p&gt;A release verifier has two jobs that are easy to blur together. It must understand the representation produced by the build chain, and it must enforce the policy that decides whether an artifact may leave that chain.&lt;/p&gt;
&lt;p&gt;Those jobs need different attitudes.&lt;/p&gt;
&lt;p&gt;Decoding can be narrowly tolerant of documented, meaning-preserving variation. Release policy should remain exact and fail closed.&lt;/p&gt;
&lt;p&gt;That distinction surfaced in a recent mobile build repair. A generated JSON settings file could begin with the optional UTF-8 byte-order marker. The JSON content was valid, but the verifier decoded it as plain UTF-8 and failed before it could evaluate the values that actually governed release safety.&lt;/p&gt;
&lt;p&gt;The fix was not to “clean up” the input generally. It was to accept one specific encoding variation, then run the same strict semantic checks as before.&lt;/p&gt;
&lt;h3 id=&quot;representation-is-not-policy&quot;&gt;Representation is not policy&lt;/h3&gt;
&lt;p&gt;A byte-order marker sits at the representation boundary. In this case, accepting UTF-8 with or without that marker did not change the JSON values.&lt;/p&gt;
&lt;p&gt;An endpoint or feature-handoff setting is different. It changes what the package will do after installation. That is policy-bearing data.&lt;/p&gt;
&lt;p&gt;Treating both categories as raw bytes creates a brittle gate: a harmless encoding detail can block a valid package. Treating both categories as broadly normalisable creates a permissive gate: a meaningful difference may be trimmed, defaulted, coerced, or discarded before verification sees it.&lt;/p&gt;
&lt;p&gt;The useful middle ground is narrow tolerance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Decode only the representation variants the producer is documented to emit.&lt;/li&gt;
&lt;li&gt;Parse into an explicit data shape.&lt;/li&gt;
&lt;li&gt;Compare policy-bearing values exactly.&lt;/li&gt;
&lt;li&gt;Publish nothing until every check has passed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a small design choice, but it keeps compatibility and authority in their proper layers.&lt;/p&gt;
&lt;h3 id=&quot;why-broad-normalisation-is-risky&quot;&gt;Why broad normalisation is risky&lt;/h3&gt;
&lt;p&gt;“Normalise before comparing” sounds sensible. Sometimes it is. The danger is that normalisation is often written without naming which differences are harmless.&lt;/p&gt;
&lt;p&gt;Should surrounding whitespace be trimmed? Should an empty string become null? Should a missing value receive a default? Should a hostname lose its trailing separator? Should key casing be ignored?&lt;/p&gt;
&lt;p&gt;Each transformation might be correct for one contract and wrong for another. Once a release gate performs all-purpose cleanup, reviewers can no longer see which deviations the gate intentionally accepts.&lt;/p&gt;
&lt;p&gt;A better question is: who owns this variation?&lt;/p&gt;
&lt;p&gt;The decoder owns permitted encoding markers. The JSON parser owns JSON syntax. The release policy owns the approved endpoint and capability settings. Package signing owns artifact identity. Device acceptance owns behaviour on the target platform.&lt;/p&gt;
&lt;p&gt;Let each boundary make only the decision it has enough information to make.&lt;/p&gt;
&lt;h3 id=&quot;test-tolerance-and-strictness-together&quot;&gt;Test tolerance and strictness together&lt;/h3&gt;
&lt;p&gt;A positive test for the newly accepted variant is necessary, but it is not enough. It proves compatibility; it does not prove that policy remained intact.&lt;/p&gt;
&lt;p&gt;The stronger test set combines the representation variant with both approved and disallowed semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a marker-bearing file with approved values passes;&lt;/li&gt;
&lt;li&gt;the verifier emits a clean, parseable receipt;&lt;/li&gt;
&lt;li&gt;a marker-bearing file with an unapproved endpoint fails;&lt;/li&gt;
&lt;li&gt;a marker-bearing file with an unapproved handoff configuration fails;&lt;/li&gt;
&lt;li&gt;either failure produces no distributable package and no success receipt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those negative cases are the important counterweight. They show that the change expanded the accepted representation, not the accepted deployment policy.&lt;/p&gt;
&lt;p&gt;The no-output assertions also matter. A verifier that reports failure after copying the package has already lost the race. Publication must happen after verification, not alongside it. The absence of a receipt is useful too: downstream automation must not mistake a partially completed run for approval.&lt;/p&gt;
&lt;h3 id=&quot;make-the-artifact-boundary-transactional&quot;&gt;Make the artifact boundary transactional&lt;/h3&gt;
&lt;p&gt;File-based pipelines rarely have a database transaction around the whole release. We can still design them with transactional thinking.&lt;/p&gt;
&lt;p&gt;Build into a private staging location. Verify the package, its embedded settings, its signature, and any required metadata there. Only after the full decision succeeds should the pipeline copy or promote the artifact and write the corresponding receipt.&lt;/p&gt;
&lt;p&gt;On failure, the public output location should remain unchanged.&lt;/p&gt;
&lt;p&gt;This ordering turns the output directory into a simple commit boundary. Before promotion, the candidate is merely work in progress. After promotion, the package and its evidence agree.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-1&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When one tool generates a file and another tool gates a release from it, review four things:&lt;/p&gt;
&lt;h4 id=&quot;1-enumerate-representation-variants&quot;&gt;1. Enumerate representation variants&lt;/h4&gt;
&lt;p&gt;Record the encodings, markers, line endings, ordering guarantees, and optional fields the producer may legitimately emit. Avoid accepting variants merely because a parser can.&lt;/p&gt;
&lt;h4 id=&quot;2-identify-policy-bearing-fields&quot;&gt;2. Identify policy-bearing fields&lt;/h4&gt;
&lt;p&gt;List the values that change destination, authority, enabled capabilities, signing identity, or runtime behaviour. Compare them deliberately rather than through a generic object cleanup step.&lt;/p&gt;
&lt;h4 id=&quot;3-add-crossed-tests&quot;&gt;3. Add crossed tests&lt;/h4&gt;
&lt;p&gt;Test each permitted representation with approved values and with at least one disallowed value. This catches the accidental move from narrow compatibility to broad permissiveness.&lt;/p&gt;
&lt;h4 id=&quot;4-prove-fail-closed-publication&quot;&gt;4. Prove fail-closed publication&lt;/h4&gt;
&lt;p&gt;Assert not only that verification returns failure, but also that no package, receipt, or other success signal appears in the publish location.&lt;/p&gt;
&lt;h3 id=&quot;the-leadership-lesson-1&quot;&gt;The leadership lesson&lt;/h3&gt;
&lt;p&gt;Strictness is not the same as rejecting every variation. Good gates are precise about what they tolerate and equally precise about what they refuse.&lt;/p&gt;
&lt;p&gt;That precision makes a pipeline easier to operate. Engineers can fix harmless toolchain differences without weakening release policy. Reviewers can see the exact compatibility allowance. Operators can trust that a failed gate did not leave an ambiguous artifact behind.&lt;/p&gt;
&lt;p&gt;The goal is not a gate that never bends. It is a gate that bends only where meaning is unchanged—and stays immovable where deployment meaning begins.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32154/tolerant-decoding-strict-release-gates</guid>
<pubDate>Fri, 23 Oct 2026 06:38:59 +0000</pubDate>
</item>
<item>
<title>An Empty Field Is Still Signed Data</title>
<link>https://coderlegion.com/32032/an-empty-field-is-still-signed-data</link>
<description>&lt;p&gt;An empty form field looks unimportant. In ordinary application logic, treating an empty value like a missing value is often reasonable. In signature verification, that assumption can reject a legitimate message—or, in a different protocol, verify a representation the sender never signed.&lt;/p&gt;
&lt;p&gt;The important lesson is simple: canonicalization is not generic data cleanup. It is part of the cryptographic protocol.&lt;/p&gt;
&lt;h3 id=&quot;the-tidy-abstraction-that-hid-a-real-difference&quot;&gt;The tidy abstraction that hid a real difference&lt;/h3&gt;
&lt;p&gt;Consider an integration with two flows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The application builds an outbound form and signs it.&lt;/li&gt;
&lt;li&gt;A provider later sends an inbound form notification that the application verifies.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both flows need URL encoding, ordered &lt;code&gt;key=value&lt;/code&gt; pairs, a digest, and a comparison. A shared helper therefore seems sensible.&lt;/p&gt;
&lt;p&gt;But suppose the protocol defines different empty-field rules. The outbound form omits empty values. The inbound notification includes every posted field, even an empty one represented as &lt;code&gt;description=&lt;/code&gt;. Reusing the outbound rule during verification silently removes part of the inbound canonical representation.&lt;/p&gt;
&lt;p&gt;Nothing is wrong with the network message. Nothing is wrong with the secret. The application simply hashes different bytes from the sender.&lt;/p&gt;
&lt;p&gt;That is the dangerous kind of bug: the code is deterministic, the crypto primitive behaves correctly, and the abstraction looks clean. The failure lives in the representation contract.&lt;/p&gt;
&lt;h3 id=&quot;empty-and-absent-are-different-inputs&quot;&gt;Empty and absent are different inputs&lt;/h3&gt;
&lt;p&gt;Application code tends to reason semantically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;note=&lt;/code&gt; means there is no useful note.&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;note&lt;/code&gt; field also means there is no useful note.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A signing algorithm reasons syntactically. It may see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;reference=abc&amp;amp;note=&amp;amp;status=complete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;as a different message from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;reference=abc&amp;amp;status=complete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same applies to field order, repeated keys, character encoding, spaces, escaping, decimal formatting, and line endings. Two representations can produce the same business meaning while producing different signatures.&lt;/p&gt;
&lt;p&gt;That is why “normalize before verifying” is unsafe advice unless the protocol defines the normalization precisely. The verifier’s job is not to improve the input. Its job is to reconstruct exactly what the other side signed.&lt;/p&gt;
&lt;h3 id=&quot;share-mechanics-not-contracts&quot;&gt;Share mechanics, not contracts&lt;/h3&gt;
&lt;p&gt;The repair pattern I prefer keeps the truly common pieces together while making direction-specific rules visible.&lt;/p&gt;
&lt;p&gt;Shared mechanics may include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the exact form-encoding routine;&lt;/li&gt;
&lt;li&gt;digest calculation;&lt;/li&gt;
&lt;li&gt;secret or passphrase handling;&lt;/li&gt;
&lt;li&gt;fixed-time signature comparison;&lt;/li&gt;
&lt;li&gt;consistent fail-closed behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The canonicalization policies remain separate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;outbound signing can omit empty fields when the outbound specification says so;&lt;/li&gt;
&lt;li&gt;inbound verification can retain empty fields when the inbound specification says so;&lt;/li&gt;
&lt;li&gt;both can exclude the signature field itself;&lt;/li&gt;
&lt;li&gt;both can preserve their required ordering.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This introduces a small amount of repetition. That is a healthy trade when the alternative is a boolean option buried inside a generic helper whose callers must remember invisible protocol rules. If a shared primitive is retained, give the public operations names and documentation that make the distinction impossible to miss.&lt;/p&gt;
&lt;h3 id=&quot;test-the-representation-not-your-implementation&quot;&gt;Test the representation, not your implementation&lt;/h3&gt;
&lt;p&gt;Signature tests often contain a subtle circularity: the test calls the production signing helper to create the expected signature, then asks the same helper to verify it. The test proves internal consistency, not compatibility with the protocol.&lt;/p&gt;
&lt;p&gt;A stronger test builds the canonical input independently. For the inbound case, start from a literal ordered field sequence containing several &lt;code&gt;key=&lt;/code&gt; entries. Calculate the expected digest with a small test-only oracle or a published fixture. Then submit the body to the real verification path.&lt;/p&gt;
&lt;p&gt;Useful negative cases include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;remove one empty field and expect rejection;&lt;/li&gt;
&lt;li&gt;change field order and expect rejection when order is significant;&lt;/li&gt;
&lt;li&gt;replace an empty field with an absent field;&lt;/li&gt;
&lt;li&gt;vary spaces and reserved characters;&lt;/li&gt;
&lt;li&gt;include a blank expected signature and fail closed;&lt;/li&gt;
&lt;li&gt;change only the signature field and confirm it is excluded from canonical input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests describe the protocol boundary directly. They are harder for a refactor to accidentally “update” into agreeing with the bug.&lt;/p&gt;
&lt;h3 id=&quot;exercise-the-framework-pipeline-too&quot;&gt;Exercise the framework pipeline too&lt;/h3&gt;
&lt;p&gt;A pure canonicalization test is necessary, but it is not sufficient for a web endpoint.&lt;/p&gt;
&lt;p&gt;Framework model binding and form parsing can consume a request stream, decode characters, merge values, or present fields through an API with different ordering behaviour. Middleware can also affect buffering or body access. A verifier that works against a handcrafted dictionary may still fail when the real HTTP pipeline handles the request first.&lt;/p&gt;
&lt;p&gt;Add one focused integration test that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;creates the literal encoded body;&lt;/li&gt;
&lt;li&gt;independently calculates the signature over that exact representation;&lt;/li&gt;
&lt;li&gt;sends an actual form POST through the application’s HTTP stack;&lt;/li&gt;
&lt;li&gt;verifies that the original body remains available where required;&lt;/li&gt;
&lt;li&gt;proves the request reaches the normal accepted outcome.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is not an argument for turning every edge case into a slow end-to-end test. Keep the combinatorial matrix in fast unit tests. Use the HTTP-level case as a boundary witness that the framework and your canonicalization contract still agree.&lt;/p&gt;
&lt;h3 id=&quot;verification-is-necessary-not-complete-trust&quot;&gt;Verification is necessary, not complete trust&lt;/h3&gt;
&lt;p&gt;Correct signature verification answers a narrow question: does this message match the shared signing contract and secret?&lt;/p&gt;
&lt;p&gt;It does not automatically prove that the event is fresh, unique, authorized for the referenced resource, or safe to apply twice. Production handlers may still need replay protection, timestamp or nonce validation, amount and ownership checks, idempotency, audit evidence, and careful failure handling.&lt;/p&gt;
&lt;p&gt;Keeping that claim bounded matters. Strong security engineering is usually a chain of explicit checks, not one impressive cryptographic step.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-2&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a signed integration, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What exact bytes or characters are signed in each direction?&lt;/li&gt;
&lt;li&gt;Are empty and missing values distinct?&lt;/li&gt;
&lt;li&gt;Is field order defined and preserved?&lt;/li&gt;
&lt;li&gt;When does decoding or encoding happen?&lt;/li&gt;
&lt;li&gt;Are repeated fields possible?&lt;/li&gt;
&lt;li&gt;Does the comparison avoid timing leaks?&lt;/li&gt;
&lt;li&gt;Do invalid inputs fail closed?&lt;/li&gt;
&lt;li&gt;Is at least one expected signature calculated independently?&lt;/li&gt;
&lt;li&gt;Does a real HTTP test cover framework parsing?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The broader engineering principle is worth carrying elsewhere: an abstraction should share sameness, not erase difference. At a cryptographic boundary, the awkward representational details are often the contract itself.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32032/an-empty-field-is-still-signed-data</guid>
<pubDate>Thu, 22 Oct 2026 06:11:15 +0000</pubDate>
</item>
<item>
<title>Put the Concurrency Gate Before the Side Effect</title>
<link>https://coderlegion.com/31923/put-the-concurrency-gate-before-the-side-effect</link>
<description>&lt;p&gt;Many concurrency bugs begin with code that looks entirely reasonable:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the current row.&lt;/li&gt;
&lt;li&gt;Check that the operation is still allowed.&lt;/li&gt;
&lt;li&gt;Create something durable elsewhere.&lt;/li&gt;
&lt;li&gt;Save the original row as complete.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Run once, the workflow is correct. Run twice at almost the same time, and both callers can pass the check before either caller records completion. If step three is irreversible, the system can create duplicate work even though every individual condition was true when evaluated.&lt;/p&gt;
&lt;p&gt;The core lesson is simple: when a state transition guards a side effect, make the database choose the winner before the side effect begins.&lt;/p&gt;
&lt;h3 id=&quot;why-read-then-check-is-not-ownership&quot;&gt;Why read-then-check is not ownership&lt;/h3&gt;
&lt;p&gt;An application-level check answers, “What did I observe?” It does not answer, “Do I exclusively own this transition?”&lt;/p&gt;
&lt;p&gt;Imagine a parent record representing an open request. Accepting one option creates a downstream record and closes the parent. Two callers can follow this timeline:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Caller A reads the parent as open.&lt;/li&gt;
&lt;li&gt;Caller B reads the same parent as open.&lt;/li&gt;
&lt;li&gt;Caller A creates a downstream record.&lt;/li&gt;
&lt;li&gt;Caller B creates another downstream record.&lt;/li&gt;
&lt;li&gt;Both callers try to close the parent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Optimistic concurrency on the final save may reject one update, but by then the rejected caller may already have created the duplicate side effect. Detecting a conflict after the irreversible action is too late.&lt;/p&gt;
&lt;h3 id=&quot;move-the-decision-forward&quot;&gt;Move the decision forward&lt;/h3&gt;
&lt;p&gt;A stronger shape puts a conditional write immediately before downstream creation. In generalized SQL, the intention is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE WorkItems
SET ClaimToken = @token, ClaimedAt = @now
WHERE Id = @id
  AND Status = 'Open'
  AND ClaimToken IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The affected-row count becomes the decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One affected row means this caller owns the transition.&lt;/li&gt;
&lt;li&gt;Zero affected rows means another caller won, or the operation is no longer valid.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That losing result is not exceptional. It is a normal concurrency outcome and should stop before any downstream creation call.&lt;/p&gt;
&lt;p&gt;This is stronger than reading again because the predicate and the update are evaluated atomically by the database. There is no application-level gap between “still open” and “now mine.”&lt;/p&gt;
&lt;h3 id=&quot;the-claim-is-a-state-not-a-boolean-trick&quot;&gt;The claim is a state, not a boolean trick&lt;/h3&gt;
&lt;p&gt;Once a claim exists, the domain has an in-progress state whether or not the schema calls it that. Treating it explicitly improves the design.&lt;/p&gt;
&lt;p&gt;The workflow needs to answer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Can a user retry while a claim is fresh?&lt;/li&gt;
&lt;li&gt;Which failures can safely release the claim?&lt;/li&gt;
&lt;li&gt;When is a claim considered abandoned?&lt;/li&gt;
&lt;li&gt;Can another caller take over an abandoned claim?&lt;/li&gt;
&lt;li&gt;What happens if the process fails after downstream creation?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A practical rule is to release ownership only when the system knows that no durable side effect exists. If downstream creation fails before producing a record, clearing the claim can make a retry safe. Once a durable result may exist, blindly clearing the claim risks creating another one.&lt;/p&gt;
&lt;h3 id=&quot;a-timeout-is-recovery-policy-not-exactly-once&quot;&gt;A timeout is recovery policy, not exactly once&lt;/h3&gt;
&lt;p&gt;A bounded stale-claim timeout prevents an interrupted process from blocking the workflow forever. A new caller may take over only if the stored claim and timestamp still match what it read, so competing recovery attempts cannot both win.&lt;/p&gt;
&lt;p&gt;That solves abandoned ownership before the side effect. It does not solve every crash window.&lt;/p&gt;
&lt;p&gt;Suppose the downstream service successfully creates a record and the process dies before the parent records completion. After the timeout, replaying the operation could create a second record. The conditional claim narrowed the risk, but it did not create a distributed transaction.&lt;/p&gt;
&lt;p&gt;For that boundary, choose an additional mechanism according to the cost of duplication:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pass a stable idempotency key to the downstream operation.&lt;/li&gt;
&lt;li&gt;Record an outbox message in the same transaction as the claim.&lt;/li&gt;
&lt;li&gt;Make downstream creation upsert by the originating command identity.&lt;/li&gt;
&lt;li&gt;Reconcile incomplete claims against downstream records before replay.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The honest claim is “one winner during concurrent admission,” not “exactly once under every failure.”&lt;/p&gt;
&lt;h3 id=&quot;test-the-overlap-not-two-sequential-calls&quot;&gt;Test the overlap, not two sequential calls&lt;/h3&gt;
&lt;p&gt;A concurrency fix deserves a test that opens the original race window deliberately.&lt;/p&gt;
&lt;p&gt;Hold the first caller inside downstream creation. While it is paused, submit the same command again and also submit a competing choice. Then release the first caller.&lt;/p&gt;
&lt;p&gt;The useful assertions are concrete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The downstream creation service was called once.&lt;/li&gt;
&lt;li&gt;One winner completed.&lt;/li&gt;
&lt;li&gt;Both competing calls received a clear losing result.&lt;/li&gt;
&lt;li&gt;The parent stored one selected result.&lt;/li&gt;
&lt;li&gt;Other candidates moved to their expected terminal state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Add two recovery tests as well. First, make downstream creation fail before producing anything and prove that a retry can acquire the released claim. Second, seed a fresh claim and an expired claim, proving that the fresh claim blocks while the expired one can be reclaimed.&lt;/p&gt;
&lt;p&gt;This test shape is more valuable than sending two tasks and hoping their timing overlaps. A controlled pause makes the race deterministic and keeps the assertion focused on the contract.&lt;/p&gt;
&lt;h3 id=&quot;a-compact-design-checklist&quot;&gt;A compact design checklist&lt;/h3&gt;
&lt;p&gt;Before shipping a command that crosses a data boundary:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Identify the first irreversible side effect.&lt;/li&gt;
&lt;li&gt;Put the ownership decision before it.&lt;/li&gt;
&lt;li&gt;Use one conditional database write, not a read-then-write promise.&lt;/li&gt;
&lt;li&gt;Treat zero affected rows as a normal loser.&lt;/li&gt;
&lt;li&gt;Release only when no durable effect exists.&lt;/li&gt;
&lt;li&gt;Define stale ownership and takeover semantics.&lt;/li&gt;
&lt;li&gt;Add idempotency or reconciliation for the post-side-effect crash window.&lt;/li&gt;
&lt;li&gt;Test with a deliberately paused winner and active losers.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The broader principle is not limited to any framework. Checks describe observations. Conditional writes grant ownership. When a workflow can create something you cannot casually undo, that distinction is part of correctness.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31923/put-the-concurrency-gate-before-the-side-effect</guid>
<pubDate>Wed, 21 Oct 2026 05:40:24 +0000</pubDate>
</item>
<item>
<title>Test the Owner, Not the Teardown</title>
<link>https://coderlegion.com/31880/test-the-owner-not-the-teardown</link>
<description>&lt;p&gt;Flaky lifecycle tests often produce a tempting diagnosis: the cleanup code must be unreliable. Sometimes that is true. Sometimes the test is asking a framework harness to prove a narrower ownership contract, and the harness is free to finish work on a different schedule.&lt;/p&gt;
&lt;p&gt;That distinction matters in interactive server-side UI systems. A component may subscribe to live updates, start a timer, register an observer, or attach a callback. The resource needs a clear owner and a reliable release path. But the test for that contract should not depend entirely on whether a whole rendered tree has completed teardown at one particular instant.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-the-failure&quot;&gt;The shape of the failure&lt;/h3&gt;
&lt;p&gt;Consider a header component that subscribes to a process-local notification service. A child pop-up also owns a separate subscription. The component test renders the tree, publishes an update, observes the visual change, disposes the test context, and immediately expects the global subscription count to be zero.&lt;/p&gt;
&lt;p&gt;That sounds reasonable. It is also broader than the claim we really want to prove.&lt;/p&gt;
&lt;p&gt;Under a full test-suite load, the notification can overlap a render. The harness teardown call may return while the renderer still has a queued batch. At that instant the header might not yet have received its disposal callback. The non-zero count is therefore ambiguous: either the header leaked, or the harness has not disposed it yet.&lt;/p&gt;
&lt;p&gt;A test with two possible explanations is a weak regression guard.&lt;/p&gt;
&lt;h3 id=&quot;waiting-is-not-the-same-as-proving&quot;&gt;Waiting is not the same as proving&lt;/h3&gt;
&lt;p&gt;The first instinct is usually to add a retry window: wait for the subscription count to reach zero, perhaps for a few seconds.&lt;/p&gt;
&lt;p&gt;That is appropriate when the product contract is genuinely eventual. It is not a universal cure for lifecycle tests. If the harness has left the component alive, waiting merely spends more time observing the wrong boundary. The test remains dependent on incidental scheduling and can still fail without a cleanup defect.&lt;/p&gt;
&lt;p&gt;The key question is not, “How long should we wait?” It is, “Which object owns the resource, and what exact behaviour are we trying to prove?”&lt;/p&gt;
&lt;h3 id=&quot;put-the-assertion-at-the-ownership-boundary&quot;&gt;Put the assertion at the ownership boundary&lt;/h3&gt;
&lt;p&gt;The stronger test calls the owning component's disposal contract directly, on the correct renderer context, and checks only the resource that component owns.&lt;/p&gt;
&lt;p&gt;If the header owns one subscription and a still-rendered child owns another, disposing the header should reduce the count by one. Expecting zero would incorrectly make the header test responsible for the child's lifetime as well.&lt;/p&gt;
&lt;p&gt;That narrower assertion is not weaker. It is more precise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the owner releases its resource;&lt;/li&gt;
&lt;li&gt;an independently owned resource is not silently claimed by the parent test;&lt;/li&gt;
&lt;li&gt;the result does not depend on whole-tree teardown timing; and&lt;/li&gt;
&lt;li&gt;removing the owner's cleanup call makes the test fail consistently.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last point is especially valuable. A small mutation check—temporarily disabling the cleanup call—shows whether the test detects the regression it claims to detect. If the test still passes, it is probably measuring something else.&lt;/p&gt;
&lt;h3 id=&quot;production-code-still-needs-lifecycle-discipline&quot;&gt;Production code still needs lifecycle discipline&lt;/h3&gt;
&lt;p&gt;A focused test does not excuse fragile implementation. Cleanup should normally be idempotent because framework callbacks, navigation, error handling, and explicit shutdown can converge on the same release path.&lt;/p&gt;
&lt;p&gt;The object should also refuse to acquire new resources after disposal. An asynchronous load may resume after the component has gone away. If its continuation subscribes again, a perfectly correct earlier disposal becomes irrelevant because nothing will release the new subscription.&lt;/p&gt;
&lt;p&gt;The same reasoning applies beyond subscriptions. Failed in-flight loads should close or abandon temporary buffers. Retained arrivals should have a sensible bound. Work dispatched to another recipient should not accidentally carry ambient request state from the publisher. These are all lifecycle questions: who owns the state, when does ownership end, and what context is allowed to cross the boundary?&lt;/p&gt;
&lt;h3 id=&quot;keep-two-layers-of-evidence&quot;&gt;Keep two layers of evidence&lt;/h3&gt;
&lt;p&gt;Direct ownership tests and integration tests answer different questions.&lt;/p&gt;
&lt;p&gt;An owner-level test asks: when this component is disposed, does it release what it owns, and can it remain released?&lt;/p&gt;
&lt;p&gt;An integration test asks: does navigation, circuit shutdown, or framework teardown actually invoke the right lifecycle path across the rendered tree?&lt;/p&gt;
&lt;p&gt;Both matter. The mistake is using only the second, broad test as proof of the first, narrow contract. When the broad test fails, you cannot tell whether ownership or orchestration is broken.&lt;/p&gt;
&lt;p&gt;Separating the layers improves diagnosis. A direct test failure points toward the owner. An integration-only failure points toward wiring, scheduling, or framework interaction. The test suite becomes a map of contracts instead of a collection of timing guesses.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-checklist&quot;&gt;A practical checklist&lt;/h3&gt;
&lt;p&gt;For each subscription, timer, observer, or callback:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Name the owner explicitly.&lt;/li&gt;
&lt;li&gt;Make release safe to call more than once.&lt;/li&gt;
&lt;li&gt;Prevent acquisition after the owner is disposed.&lt;/li&gt;
&lt;li&gt;Test the owner's release contract directly.&lt;/li&gt;
&lt;li&gt;Give child-owned resources separate coverage.&lt;/li&gt;
&lt;li&gt;Keep one integration test for the framework path.&lt;/li&gt;
&lt;li&gt;Prove the test's sensitivity by breaking cleanup locally and seeing it fail.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The trade-off is a few more focused tests and slightly more explicit lifecycle code. In return, failures become actionable. You know whether the owner leaked, the child still legitimately owns something, or the framework has not completed teardown.&lt;/p&gt;
&lt;p&gt;Reliable tests do not merely wait longer. They make ownership visible.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31880/test-the-owner-not-the-teardown</guid>
<pubDate>Tue, 20 Oct 2026 17:41:25 +0000</pubDate>
</item>
<item>
<title>Unlocking Static Polymorphism in C# 11: Static Abstract Interface Members</title>
<link>https://coderlegion.com/31746/unlocking-static-polymorphism-in-c-11-static-abstract-interface-members</link>
<description>&lt;p&gt;For years, C# interfaces were limited strictly to instance members. &lt;/p&gt;
&lt;p&gt;If you wanted to define a contract requiring a class or struct to implement a static method like a string parser or a factory method you had to rely on reflection, runtime type checks, or messy workarounds.&lt;/p&gt;
&lt;p&gt;C# 11 changed this by introducing static abstract and static virtual members in interfaces (Microsoft Learn, 2025).&lt;/p&gt;
&lt;p&gt;What Does It Look Like?&lt;br&gt;
Consider the built-in IParsable interface from the .NET class library:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface IParsable&amp;lt;TSelf&amp;gt; where TSelf : IParsable&amp;lt;TSelf&amp;gt;
{
    static abstract TSelf Parse(string s, IFormatProvider? provider);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any type implementing IParsable (such as int, Guid, or DateTime) must provide a concrete implementation of this static Parse method.&lt;/p&gt;
&lt;p&gt;Why Are They Useful?&lt;br&gt;
Before C# 11, interfaces could only enforce behavior on object instances. Allowing static abstract members solves several everyday C# design challenges:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Type-Safe Factories and Parsers: You can write generic methods that&lt;br&gt;
rely on types to create or parse themselves, completely eliminating&lt;br&gt;
the need for slow runtime reflection (Almada, 2023).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compile-Time Polymorphism: The compiler resolves static interface&lt;br&gt;
   calls at compile time, ensuring maximum performance with zero runtime&lt;br&gt;
   overhead (Microsoft Learn, 2025).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clean Contract Enforcement: Domain models can cleanly enforce that&lt;br&gt;
   implementing types provide specific static builder or creation&lt;br&gt;
   methods.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A Simple Code Example&lt;br&gt;
Here is how you can write a straightforward generic method that parses any IParsable type:&lt;/p&gt;
&lt;p&gt;C#&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static T ParseValue&amp;lt;T&amp;gt;(string input) where T : IParsable&amp;lt;T&amp;gt;
{
    return T.Parse(input, null);
}

int number = ParseValue&amp;lt;int&amp;gt;(&quot;123&quot;);
DateTime date = ParseValue&amp;lt;DateTime&amp;gt;(&quot;2026-06-06&quot;);

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By adding the constraint where T : IParsable, the compiler guarantees that T has a static Parse method, making your generic utility code both clean and 100% type-safe.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;References Almada, A. (2023). Generic math in .NET. Antão Almada&lt;br&gt;
Blog. &lt;a rel=&quot;nofollow&quot; href=&quot;https://antaoalmada.dev/posts/Generic-math-in-dotnet/&quot;&gt;https://antaoalmada.dev/posts/Generic-math-in-dotnet/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Microsoft Learn. (2025). Generic interfaces (C# Programming Guide).&lt;br&gt;
Microsoft Corporation.&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generic-interfaces&quot;&gt;https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generic-interfaces&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31746/unlocking-static-polymorphism-in-c-11-static-abstract-interface-members</guid>
<pubDate>Mon, 19 Oct 2026 15:15:36 +0000</pubDate>
</item>
<item>
<title>Hand Off Intent, Not Authority</title>
<link>https://coderlegion.com/31588/hand-off-intent-not-authority</link>
<description>&lt;p&gt;Moving a user from one application host to another often looks harmless. A button opens a browser, the user continues, and the original client waits for them to return.&lt;/p&gt;
&lt;p&gt;Architecturally, that is not merely navigation. It is a trust-boundary decision.&lt;/p&gt;
&lt;p&gt;This becomes especially important when one user experience runs across several .NET hosts: perhaps an ASP.NET Core site, a Blazor WebAssembly client, and a .NET MAUI hybrid app. The shared components may look identical, but the hosts do not own the same secrets, sessions, callbacks, or security guarantees.&lt;/p&gt;
&lt;p&gt;The lesson is simple: when a workflow crosses hosts, hand off intent, not authority.&lt;/p&gt;
&lt;h3 id=&quot;the-tempting-shortcut&quot;&gt;The tempting shortcut&lt;/h3&gt;
&lt;p&gt;Suppose a client has already loaded an order-like resource and can display the next sensitive action. It is tempting to let every host render the same provider form or carry the current access proof into the browser.&lt;/p&gt;
&lt;p&gt;That preserves continuity, but it also widens the boundary. A client that only needed to display state may now receive signed fields, transaction details, callback assumptions, or a reusable proof. Putting any of that into a query string or fragment creates more problems: URLs are copied, logged, cached, placed in browser history, exposed to extensions, and sometimes sent as referrers.&lt;/p&gt;
&lt;p&gt;Shared UI does not imply shared authority. Reuse the presentation and state model where it helps, but make each host's capabilities explicit.&lt;/p&gt;
&lt;h3 id=&quot;a-narrower-handoff&quot;&gt;A narrower handoff&lt;/h3&gt;
&lt;p&gt;A safer design gives the less-trusted client one small capability: open a fixed public entry point on the authoritative host.&lt;/p&gt;
&lt;p&gt;The destination should be deliberately boring. It should not contain an order reference, email address, one-time code, access token, signed provider fields, or a claimed outcome. It should have no dynamic query string or fragment. It communicates only this intent: “the user wants to continue this workflow on the web.”&lt;/p&gt;
&lt;p&gt;The authoritative host then starts again from its own security context. It asks for the identifiers appropriate to the workflow, performs anti-forgery checks, verifies ownership through its normal mechanism, and only then exposes the sensitive action.&lt;/p&gt;
&lt;p&gt;That separation gives each statement a precise meaning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Opening the browser is navigation.&lt;/li&gt;
&lt;li&gt;Proving ownership grants access in the destination host.&lt;/li&gt;
&lt;li&gt;Starting the action is a separate command.&lt;/li&gt;
&lt;li&gt;A callback or authoritative read confirms the outcome.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of those statements is allowed to stand in for another.&lt;/p&gt;
&lt;h3 id=&quot;treat-public-configuration-as-an-api&quot;&gt;Treat public configuration as an API&lt;/h3&gt;
&lt;p&gt;Installable browser clients need runtime configuration, but a generated client configuration document is still a public API. Do not serialize a server configuration tree and hope that only safe values happen to be present.&lt;/p&gt;
&lt;p&gt;Build a small allowlist instead. For this kind of handoff, the browser might receive an enablement flag and a reviewed public origin. That is enough. Return the document with a no-store policy so a stale capability does not linger after an operational change.&lt;/p&gt;
&lt;p&gt;Validate the destination before the application starts. Require the exact shape you intend: HTTPS, a root origin, no embedded credentials, no path, no query, no fragment, and no unexpected local or alternate-port destination. Keep the feature disabled when configuration is absent. If someone explicitly enables it with an invalid value, fail startup with a value-free error rather than echoing the bad input into logs.&lt;/p&gt;
&lt;p&gt;Failing closed here is not pessimism. It turns configuration from ambient text into an enforceable security boundary.&lt;/p&gt;
&lt;h3 id=&quot;make-the-destination-defensive-too&quot;&gt;Make the destination defensive too&lt;/h3&gt;
&lt;p&gt;The source client should generate a clean URL, but the destination should not trust that promise.&lt;/p&gt;
&lt;p&gt;If someone arrives with a query string, canonicalize to the fixed entry before rendering. Apply no-store and a restrictive referrer policy. Do not pre-fill sensitive identifiers from URL input. Start with the ordinary ownership flow and its existing anti-forgery protection.&lt;/p&gt;
&lt;p&gt;This matters because links escape their intended callers. They are bookmarked, pasted into messages, opened by scanners, and reconstructed by users. A robust boundary remains safe even when the source is not your application.&lt;/p&gt;
&lt;h3 id=&quot;failure-must-remain-boring&quot;&gt;Failure must remain boring&lt;/h3&gt;
&lt;p&gt;A browser launch can fail for mundane reasons: platform policy, missing handlers, lifecycle changes, or an exception from the native shell. None of those failures should mutate domain state.&lt;/p&gt;
&lt;p&gt;The client should report only that the launch did not happen. It should not create the sensitive form, mark work as started, grant access, or imply success. Concurrent clicks can be collapsed while an open attempt is running, but a failed attempt should be retryable.&lt;/p&gt;
&lt;p&gt;This is a useful design test: if navigation failure requires compensation, the handoff probably carried too much authority.&lt;/p&gt;
&lt;h3 id=&quot;test-the-negative-space-1&quot;&gt;Test the negative space&lt;/h3&gt;
&lt;p&gt;Most happy-path tests prove that a button eventually opens the right page. Boundary tests need to prove what cannot happen.&lt;/p&gt;
&lt;p&gt;Useful tests include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the capability is off by default;&lt;/li&gt;
&lt;li&gt;malformed enablement and invalid destinations fail safely;&lt;/li&gt;
&lt;li&gt;the client opens only the fixed path;&lt;/li&gt;
&lt;li&gt;the URL contains no proof or private state;&lt;/li&gt;
&lt;li&gt;each host renders only its intended action;&lt;/li&gt;
&lt;li&gt;public configuration contains only allowlisted fields;&lt;/li&gt;
&lt;li&gt;the web entry removes unexpected query data before rendering;&lt;/li&gt;
&lt;li&gt;caching and referrer protections are present;&lt;/li&gt;
&lt;li&gt;ownership and anti-forgery checks happen in the destination;&lt;/li&gt;
&lt;li&gt;launch failure changes no workflow state; and&lt;/li&gt;
&lt;li&gt;repeated clicks do not create duplicate launches.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are negative properties. They are easy to lose during refactoring unless tests name them directly.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-honest-friction&quot;&gt;The trade-off is honest friction&lt;/h3&gt;
&lt;p&gt;Fresh ownership proof adds a step. A dedicated entry route, host-specific capability registration, and configuration validation add code. The boundary also needs maintenance as new hosts appear.&lt;/p&gt;
&lt;p&gt;That cost buys something valuable: the sensitive operation stays with the host designed to own it. The other clients remain simpler, public configuration stays small, and failures have a narrow blast radius.&lt;/p&gt;
&lt;p&gt;When reviewing any cross-host workflow, write down four things: the exact destination, the data allowed to cross, the authority that must be re-established, and the state a failed launch may change. If those answers are vague, the implementation probably is too.&lt;/p&gt;
&lt;p&gt;Intent can cross a boundary. Authority should have to earn its way back in.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31588/hand-off-intent-not-authority</guid>
<pubDate>Sun, 18 Oct 2026 08:36:51 +0000</pubDate>
</item>
<item>
<title>Soft Delete Is a Model Contract, Not a Repository Habit</title>
<link>https://coderlegion.com/31493/soft-delete-is-a-model-contract-not-a-repository-habit</link>
<description>&lt;p&gt;Soft deletion often begins innocently: add an IsDeleted flag, remember to exclude retained rows, and move on. That works until the application grows into several modules, several DbContext types, and configuration spread across assemblies.&lt;/p&gt;
&lt;p&gt;At that point, soft deletion is no longer a local query concern. It is a visibility boundary for the complete EF Core model.&lt;/p&gt;
&lt;p&gt;The difference is practical. A repository predicate protects the query paths that remember to use it. A model contract protects ordinary reads by default and makes exceptional reads visible in code review.&lt;/p&gt;
&lt;h3 id=&quot;the-dangerous-partial-success&quot;&gt;The dangerous partial success&lt;/h3&gt;
&lt;p&gt;Imagine a shared base context that scans the entity types it knows and installs a query filter. Later, a module applies another configuration assembly and adds more entities. The base code is correct for the model it saw, but its timing was wrong. Some retained rows are hidden and others are returned normally.&lt;/p&gt;
&lt;p&gt;This failure is uncomfortable because both sides look reasonable in isolation. The entity has the deletion marker. The shared context contains filter code. A few tests pass. Yet the effective model has uneven visibility rules.&lt;/p&gt;
&lt;p&gt;The lesson is to configure cross-cutting rules at a lifecycle point where the model is complete. An EF Core model-finalising convention can inspect every registered type after the contexts and configuration assemblies have contributed their mappings.&lt;/p&gt;
&lt;p&gt;For each root entity carrying the deletion marker, the convention can add the standard predicate. Owned types follow their owner, and derived types inherit the root filter. Existing filters must be preserved: combine with an anonymous filter, or add a separate named filter when the model uses named filters. Any always-visible audit type should be an explicit, narrow exception rather than an accidental omission.&lt;/p&gt;
&lt;h3 id=&quot;test-the-effective-predicate&quot;&gt;Test the effective predicate&lt;/h3&gt;
&lt;p&gt;Checking that a filter exists is too weak.&lt;/p&gt;
&lt;p&gt;An entity might have an unrelated visibility filter. It might even mention the deletion marker beneath a logical OR:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;not deleted OR visible to this user
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That expression can still admit a retained row. The required property is stronger: not deleted must be a mandatory conjunct of the effective filter.&lt;/p&gt;
&lt;p&gt;A reusable model audit can enumerate all root entity types with the marker, exclude only documented exceptions, and fail if any type lacks that mandatory predicate. It should have tests against itself: prove that it rejects an unrelated filter and rejects the OR case. Otherwise the audit risks becoming a green check that does not guard the boundary it names.&lt;/p&gt;
&lt;p&gt;Provider-backed tests add the next layer of evidence. Insert one active row and one retained row. Show that normal queries return only the active row, while an explicit bypass can retrieve both. This verifies the generated query and provider behaviour rather than only evaluating a compiled expression in memory.&lt;/p&gt;
&lt;h3 id=&quot;a-correct-filter-changes-workflows&quot;&gt;A correct filter changes workflows&lt;/h3&gt;
&lt;p&gt;When global filtering becomes consistent, previously accidental behaviour may break. That is useful information.&lt;/p&gt;
&lt;p&gt;Restoration is the clearest example. Suppose a retained row still owns a unique business slot. A normal lookup cannot see it, so a naive create path tries to insert a replacement and collides with the database constraint.&lt;/p&gt;
&lt;p&gt;The solution is not to weaken the global filter. Give the restoration path an explicit way to ignore model filters, then make its intention precise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;creating into a slot held by a retained row restores that same row;&lt;/li&gt;
&lt;li&gt;editing a different active row into the reserved slot is refused;&lt;/li&gt;
&lt;li&gt;failure leaves both rows unchanged;&lt;/li&gt;
&lt;li&gt;historical or restoration reads are the only paths allowed to bypass the boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Putting the bypass on a named specification or dedicated query object is easier to review than scattering IgnoreQueryFilters() through services. The name communicates why the exception exists, and focused tests can pin its scope.&lt;/p&gt;
&lt;h3 id=&quot;watch-required-navigations&quot;&gt;Watch required navigations&lt;/h3&gt;
&lt;p&gt;Global filters also interact with relationship shape. If a required navigation uses an inner join and the principal is filtered out, the dependent can disappear from a query even when the dependent itself is active.&lt;/p&gt;
&lt;p&gt;That may be correct, but it should be a decision.&lt;/p&gt;
&lt;p&gt;An inventory of required relationships that cross filtered and unfiltered types gives reviewers a bounded list of hazards. For each one, choose deliberately: align filters on both sides, make the navigation optional, change the query shape, or accept and test the disappearance.&lt;/p&gt;
&lt;p&gt;This is one reason metadata-driven verification scales better than a long list of hand-written repository tests. The model can tell you what exists now, including a new entity or relationship added by a future module.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-verification-stack&quot;&gt;A practical verification stack&lt;/h3&gt;
&lt;p&gt;A durable soft-delete boundary has four layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Apply the convention after the model is complete.&lt;/li&gt;
&lt;li&gt;Audit every soft-deletable root for the exact mandatory predicate.&lt;/li&gt;
&lt;li&gt;Keep bypasses explicit and limited to restoration or historical use.&lt;/li&gt;
&lt;li&gt;Run real-provider tests for visibility, restoration, uniqueness, and required-navigation behaviour.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There is a cost: more model infrastructure, a deliberate exception list, and tests that can expose old assumptions. Some teams may decide that hard deletion or temporal tables better match particular data. The point is not that soft deletion is always right.&lt;/p&gt;
&lt;p&gt;The point is that, once chosen, it must behave like one coherent contract.&lt;/p&gt;
&lt;p&gt;The most valuable outcome is not the filter itself. It is that a future entity cannot quietly arrive outside the boundary—and that exceptional access reads like an exception.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31493/soft-delete-is-a-model-contract-not-a-repository-habit</guid>
<pubDate>Sat, 17 Oct 2026 05:45:59 +0000</pubDate>
</item>
<item>
<title>Queued Work Is a Snapshot, Not the Truth</title>
<link>https://coderlegion.com/31281/queued-work-is-a-snapshot-not-the-truth</link>
<description>&lt;p&gt;Background work is usually introduced to make a system more reliable. We persist an intent, let the request finish, and deliver the external side effect later. That protects the user-facing path from temporary provider failures and gives us room for retries.&lt;/p&gt;
&lt;p&gt;It also creates a time gap.&lt;/p&gt;
&lt;p&gt;Inside that gap, the world can change. A task can be completed. A new task can appear. A recipient can lose access. A preference can change. Another worker can advance the same operation. The queued record may still be durable and validly serialized while the message it would produce is already wrong.&lt;/p&gt;
&lt;p&gt;The lesson is simple: queued intent is evidence about an earlier snapshot. It is not timeless authority.&lt;/p&gt;
&lt;h3 id=&quot;store-the-promise-not-the-presentation&quot;&gt;Store the promise, not the presentation&lt;/h3&gt;
&lt;p&gt;Suppose a service groups several outstanding items into one reminder. The tempting design is to serialize the titles, amounts, names, and finished message into an outbox row. Delivery is then easy: deserialize and send.&lt;/p&gt;
&lt;p&gt;That convenience turns the row into a stale cache of user-facing truth. It may also retain more sensitive data than the queue needs.&lt;/p&gt;
&lt;p&gt;A safer durable record can be much smaller. Keep the routing identity, recipient identity, sequence, source, and a deterministic fingerprint of the complete set the message was created for. The fingerprint represents the promise: “this message describes exactly this observed set.”&lt;/p&gt;
&lt;p&gt;The display payload is rebuilt later from authoritative state. The durable row says what snapshot was intended without pretending that its old presentation is still current.&lt;/p&gt;
&lt;h3 id=&quot;rebuild-from-the-complete-current-set&quot;&gt;Rebuild from the complete current set&lt;/h3&gt;
&lt;p&gt;At execution time, re-read what the authoritative source currently exposes to the recipient. Then run the same grouping rules again and compare the resulting fingerprint with the stored fingerprint.&lt;/p&gt;
&lt;p&gt;Why the complete set? Because stale work changes in two directions.&lt;/p&gt;
&lt;p&gt;The obvious case is subtraction. An item was completed or removed after the reminder was queued. The old message now overstates what is outstanding.&lt;/p&gt;
&lt;p&gt;The less obvious case is addition. A new item appeared after the reminder was queued. If the worker checks only the item keys remembered by the row, every remembered item may still be valid. The worker can still send a message that understates the current request.&lt;/p&gt;
&lt;p&gt;Reconstructing the whole set catches both. If the current fingerprint differs, the old intent is superseded. Stop it. Let the current state produce a new intent under a new identity.&lt;/p&gt;
&lt;h3 id=&quot;keep-authorization-in-the-authoritative-read&quot;&gt;Keep authorization in the authoritative read&lt;/h3&gt;
&lt;p&gt;The background worker should not invent a second, simplified version of the authorization and status rules used by the foreground feature. That duplicate will drift.&lt;/p&gt;
&lt;p&gt;Prefer a shared underlying read path with an explicit recipient parameter. The interactive request and the delayed worker should agree on what the recipient is currently allowed to see and how current status is mapped.&lt;/p&gt;
&lt;p&gt;This also makes an important distinction possible. “Nothing remains” is not the same as “the recipient is no longer authorized.” Both may yield an empty visible set, but they mean different things operationally and in an audit trail.&lt;/p&gt;
&lt;p&gt;Completion can finish the old intent. Revocation should stop disclosure for a security reason. A missing source integration is different again: it is usually a retryable deployment fault, not evidence that the work is complete.&lt;/p&gt;
&lt;h3 id=&quot;model-more-than-send-or-fail&quot;&gt;Model more than send or fail&lt;/h3&gt;
&lt;p&gt;A robust recheck tends to produce several outcomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Send: the complete current snapshot still matches, preferences allow delivery, and the operation sequence is current.&lt;/li&gt;
&lt;li&gt;Stop as completed: the intended work no longer exists.&lt;/li&gt;
&lt;li&gt;Stop as changed: the set still exists but is no longer the set the queued message promised to describe.&lt;/li&gt;
&lt;li&gt;Stop as revoked: the recipient no longer has authority.&lt;/li&gt;
&lt;li&gt;Stop as superseded: a later sequence has already advanced the operation.&lt;/li&gt;
&lt;li&gt;Defer: authoritative evaluation is temporarily unavailable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These outcomes can feel verbose compared with a Boolean. That verbosity is useful. It prevents a temporary infrastructure gap from being filed as success, and it prevents a security revocation from being misreported as ordinary completion.&lt;/p&gt;
&lt;h3 id=&quot;test-addition-as-carefully-as-subtraction&quot;&gt;Test addition as carefully as subtraction&lt;/h3&gt;
&lt;p&gt;The most valuable regression test in this pattern is often the one teams forget: add something after the row is written.&lt;/p&gt;
&lt;p&gt;A focused suite should cover an unchanged set, a removed item, an added item, an empty set, revoked authority, changed preferences, a superseded sequence, and an unavailable source. It should also prove that each evaluation performs a fresh authoritative read rather than accidentally reusing stored data.&lt;/p&gt;
&lt;p&gt;These tests do more than exercise branches. They define the temporal contract of the workflow: which facts may age, which facts must be refreshed, and which outcome owns each kind of change.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real-1&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;This design adds a database read at the most sensitive point, deterministic reconstruction, fingerprint maintenance, and more explicit state transitions. At high volume, that cost deserves measurement. Poorly indexed rechecks can simply move a reliability problem into the database.&lt;/p&gt;
&lt;p&gt;The benefit is not that fingerprints make distributed systems magically consistent. They do not. There is still a race between the final read and the external side effect, and systems with stricter requirements may need provider idempotency, transactional boundaries, or domain-specific reservations.&lt;/p&gt;
&lt;p&gt;The narrower claim is valuable enough: a delayed worker should not knowingly act on an obsolete snapshot when it can cheaply revalidate current truth.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-3&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing delayed email, push, webhook, export, billing, or approval work, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What exact snapshot did this row promise to act on?&lt;/li&gt;
&lt;li&gt;Does the queue store minimal identity or stale presentation data?&lt;/li&gt;
&lt;li&gt;Can the worker re-read the full authoritative set?&lt;/li&gt;
&lt;li&gt;Are additions detected as well as removals?&lt;/li&gt;
&lt;li&gt;Are completion, revocation, supersession, and temporary unavailability distinct?&lt;/li&gt;
&lt;li&gt;Is the external side effect itself idempotent or otherwise bounded?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Durability keeps work from disappearing. Revalidation keeps durable work from becoming confidently wrong.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31281/queued-work-is-a-snapshot-not-the-truth</guid>
<pubDate>Fri, 16 Oct 2026 05:43:57 +0000</pubDate>
</item>
<item>
<title>Isolation Is Sometimes a Feature, Not a Smell</title>
<link>https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</link>
<description>&lt;p&gt;Two projects expose similar helpers. One has no project references. It is missing from the solution most developers build every day. The obvious reaction is understandable: share the code, add the reference, and bring the lonely project back into the fold.&lt;/p&gt;
&lt;p&gt;That cleanup can be exactly wrong.&lt;/p&gt;
&lt;p&gt;A recent architecture correction I reviewed was a useful reminder that repository shape is not the whole system. Build agents, installed workloads, runtime origins, signing steps, and release pipelines are part of the architecture too. An isolated .NET MAUI head looked neglected when viewed only from the solution file. Viewed from its release path, the isolation was load-bearing.&lt;/p&gt;
&lt;h3 id=&quot;start-with-the-delivery-graph&quot;&gt;Start With the Delivery Graph&lt;/h3&gt;
&lt;p&gt;Before deduplicating across project boundaries, ask how each project is actually built and shipped.&lt;/p&gt;
&lt;p&gt;In the case I reviewed, a server-focused solution deliberately excluded the mobile head. That kept routine server builds from requiring mobile workloads on every developer machine and CI runner. The mobile app was still real software: a separate pipeline restored, built, signed, and released it independently.&lt;/p&gt;
&lt;p&gt;Adding a shared project reference would therefore do more than remove a few repeated lines. It would expand the mobile restore graph and couple its release path to a project the mobile pipeline had never needed. Adding the mobile project to the server solution would impose a different cost in the other direction: machines building the server product would now need tooling they did not otherwise use.&lt;/p&gt;
&lt;p&gt;Neither consequence is visible in a tidy dependency diagram. Both matter in production delivery.&lt;/p&gt;
&lt;h3 id=&quot;similar-code-can-encode-different-assumptions&quot;&gt;Similar Code Can Encode Different Assumptions&lt;/h3&gt;
&lt;p&gt;The same review contained two link resolvers that appeared ripe for consolidation. The browser implementation derived an external base address from its hosting origin. That was valid because the browser app was served by the web host.&lt;/p&gt;
&lt;p&gt;The mobile app ran inside an embedded web view. Its internal origin did not identify the external service it needed to reach. Reusing the browser resolver would not create elegant sharing; it would import an invalid environment assumption.&lt;/p&gt;
&lt;p&gt;This is a useful test for any proposed abstraction: are the implementations merely similar in syntax, or are they equivalent in authority and context?&lt;/p&gt;
&lt;p&gt;Imagine a browser client and a desktop client that both produce a download URL. The browser can safely resolve a relative path against the page origin. The desktop client may need an explicitly configured service endpoint. A common interface can still be useful, but a common implementation may be actively misleading.&lt;/p&gt;
&lt;p&gt;Duplication is not automatically good. It is sometimes the smallest honest representation of two different worlds.&lt;/p&gt;
&lt;h3 id=&quot;guard-the-constraint-and-its-reason&quot;&gt;Guard the Constraint—and Its Reason&lt;/h3&gt;
&lt;p&gt;Documentation corrected the misleading story, but the stronger improvement was an architecture test suite.&lt;/p&gt;
&lt;p&gt;The tests did not merely assert that the mobile head had no project references and stayed outside the server solution. They also checked that the separate mobile build path still existed. That second half changes the character of the rule.&lt;/p&gt;
&lt;p&gt;Without it, the test says: “keep this isolated forever.”&lt;/p&gt;
&lt;p&gt;With it, the test says: “keep this isolated while this independent delivery path remains true.”&lt;/p&gt;
&lt;p&gt;That is a healthier architectural guard. It protects a decision without pretending the decision is timeless. If the separate pipeline disappears, the guard fails and asks maintainers to reconsider the boundary. The test becomes executable rationale rather than fossilized preference.&lt;/p&gt;
&lt;p&gt;This pattern generalizes well. If a component must stay dependency-free because it is loaded by a restricted host, test both the dependency rule and the existence of that host. If a migration assembly must remain separate because tooling discovers it independently, guard both the assembly boundary and the discovery path.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-still-real&quot;&gt;The Trade-Off Is Still Real&lt;/h3&gt;
&lt;p&gt;Deliberate isolation has costs.&lt;/p&gt;
&lt;p&gt;Separate helpers can drift. A visual shell copied for a second head may receive fixes later. Architecture tests add maintenance, and source-shape checks can become brittle if they assert incidental text instead of meaningful structure.&lt;/p&gt;
&lt;p&gt;The answer is not to ignore those costs. It is to compare them with the coupling being avoided.&lt;/p&gt;
&lt;p&gt;Prefer the smallest duplication that preserves the boundary. Keep behavioural contracts aligned where the behaviour truly is shared. Document the assumptions that must differ. Review the boundary when release topology changes. And avoid broad claims such as “this project shares nothing” when the important truth is more precise: it ships independently and intentionally owns a narrow set of platform-specific choices.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-4&quot;&gt;A Practical Review Checklist&lt;/h3&gt;
&lt;p&gt;Before merging apparently duplicate code across application heads, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which pipeline builds and releases each head?&lt;/li&gt;
&lt;li&gt;What workloads and SDKs does each build environment guarantee?&lt;/li&gt;
&lt;li&gt;Will a new reference expand the restore or signing boundary?&lt;/li&gt;
&lt;li&gt;Do the implementations rely on different origins, storage, identity, or lifecycle rules?&lt;/li&gt;
&lt;li&gt;Can we test the constraint and the continuing fact that justifies it?&lt;/li&gt;
&lt;li&gt;What event should trigger reconsideration of the decision?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These questions turn “DRY versus duplication” into a more useful discussion about operational ownership.&lt;/p&gt;
&lt;h3 id=&quot;architecture-includes-the-way-software-leaves-the-repository&quot;&gt;Architecture Includes the Way Software Leaves the Repository&lt;/h3&gt;
&lt;p&gt;The most valuable lesson for me was a leadership one: do not label unusual structure before tracing its history and delivery path.&lt;/p&gt;
&lt;p&gt;An isolated project may be forgotten. It may also be a carefully bounded product whose release assumptions are invisible from the main solution. We should investigate before simplifying, explain the decision in language that cannot be mistaken for neglect, and encode the rationale where future changes will encounter it.&lt;/p&gt;
&lt;p&gt;Clean architecture is not the fewest projects or the fewest repeated lines. It is a system whose dependencies match the realities of how it runs and ships—and whose exceptions are specific, reviewable, and allowed to expire.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</guid>
<pubDate>Thu, 15 Oct 2026 06:01:03 +0000</pubDate>
</item>
<item>
<title>A Passing Test Can Still Prove Nothing</title>
<link>https://coderlegion.com/31018/a-passing-test-can-still-prove-nothing</link>
<description>&lt;p&gt;Some of the most misleading tests are not flaky, slow, or obviously incomplete. They are fast, deterministic, and green.&lt;/p&gt;
&lt;p&gt;They also pass for the wrong reason.&lt;/p&gt;
&lt;p&gt;That is especially dangerous in fault-tolerant code. A test may claim that a failed dependency is contained, a fallback runs, or an established workflow continues. Yet its assertion may observe an outcome that can occur both before and after the recovery boundary. The suite reports confidence while the behaviour named by the test remains unproved.&lt;/p&gt;
&lt;h3 id=&quot;the-false-positive-shape&quot;&gt;The false-positive shape&lt;/h3&gt;
&lt;p&gt;Imagine a dispatcher with an optional policy source in front of an established workflow. The intended behaviour is straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ask the policy source whether the old workflow should stand down.&lt;/li&gt;
&lt;li&gt;If that optional source fails, contain the fault.&lt;/li&gt;
&lt;li&gt;Continue into the established downstream path.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The unit test configures a policy source that throws. It also uses a strict downstream test double that throws if reached, because the rest of that workflow is outside the test's concern.&lt;/p&gt;
&lt;p&gt;Then it asserts only that dispatch throws an exception.&lt;/p&gt;
&lt;p&gt;At first glance, this can feel reasonable. An exception from the strict downstream dependency appears to prove that execution continued. But the same broad assertion also accepts the earlier exception from the policy source. If the recovery logic is deleted, the first exception simply escapes and the test remains green.&lt;/p&gt;
&lt;p&gt;The assertion recognizes the shape of the final outcome, not the path that produced it.&lt;/p&gt;
&lt;h3 id=&quot;test-the-claim-not-the-punctuation&quot;&gt;Test the claim, not the punctuation&lt;/h3&gt;
&lt;p&gt;The claim is not “an exception occurs.” The claim is “a fault at this boundary does not stop progress into the established path.”&lt;/p&gt;
&lt;p&gt;That distinction changes what the test must observe.&lt;/p&gt;
&lt;p&gt;A useful test needs a stable witness on the far side of the boundary. Depending on the design, that witness could be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a recorded call to the downstream collaborator;&lt;/li&gt;
&lt;li&gt;a domain result that only the fallback path can produce;&lt;/li&gt;
&lt;li&gt;a distinct exception identity from a deliberately strict dependency;&lt;/li&gt;
&lt;li&gt;a state transition committed after the failed operation; or&lt;/li&gt;
&lt;li&gt;an emitted event that occurs only after recovery has completed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The specific technique matters less than the causal guarantee. The witness must be impossible—or at least deliberately difficult—to observe if execution stops at the failure being contained.&lt;/p&gt;
&lt;p&gt;In the example above, the corrected test distinguishes the downstream failure from the policy-source failure. It therefore passes only when execution crosses the recovery boundary. A recording test double would also work and may be clearer when the downstream call itself is the contract.&lt;/p&gt;
&lt;h3 id=&quot;why-mutation-testing-helped&quot;&gt;Why mutation testing helped&lt;/h3&gt;
&lt;p&gt;Code coverage could not expose this weakness. Both the recovery code and the test might execute during the normal run. Coverage tells us which lines ran; it does not tell us whether an assertion depended on their behaviour.&lt;/p&gt;
&lt;p&gt;Mutation testing asks a sharper question: if a meaningful piece of behaviour is changed or removed, does a test fail?&lt;/p&gt;
&lt;p&gt;Removing the recovery behaviour created a surviving mutant. That was valuable evidence. It did not necessarily mean the production design was wrong. It meant the suite could not distinguish the intended implementation from a broken one.&lt;/p&gt;
&lt;p&gt;This is one of mutation testing's best uses: finding assertions that observe something adjacent to the claim rather than the claim itself.&lt;/p&gt;
&lt;p&gt;You do not need mutation tooling to apply the same reasoning in review. Mentally invert the behaviour:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the catch disappeared, would the test fail?&lt;/li&gt;
&lt;li&gt;If the fallback were skipped, would the test fail?&lt;/li&gt;
&lt;li&gt;If execution stopped one step early, would the test fail?&lt;/li&gt;
&lt;li&gt;Could a different branch produce the same asserted result?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the answer is uncomfortable, look for a witness beyond the boundary.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-path-awareness-without-brittleness&quot;&gt;The trade-off: path awareness without brittleness&lt;/h3&gt;
&lt;p&gt;Path-sensitive assertions carry a real cost. A test that verifies every internal call order can become coupled to implementation detail and obstruct harmless refactoring. Replacing one collaborator with another should not require rewriting an entire suite if the observable contract is unchanged.&lt;/p&gt;
&lt;p&gt;The answer is not to avoid path awareness altogether. It is to choose the smallest stable witness that represents the behaviour promised by the test.&lt;/p&gt;
&lt;p&gt;Prefer domain-visible evidence when it exists: a returned decision, a committed state, a durable message, or a recorded recovery status. Use collaborator verification when crossing that collaborator boundary is itself the contract. Use exception type or identity when a strict test double intentionally marks progress, but make that intent explicit so a future reader understands why the exception matters.&lt;/p&gt;
&lt;p&gt;This produces a useful hierarchy:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Assert the externally meaningful result when it uniquely proves recovery.&lt;/li&gt;
&lt;li&gt;Assert a durable checkpoint when the final result is produced asynchronously.&lt;/li&gt;
&lt;li&gt;Assert a boundary interaction when progress across that boundary is the behaviour under test.&lt;/li&gt;
&lt;li&gt;Use a distinct failure identity as a narrow witness when a strict dependency deliberately terminates the test.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The closer the witness is to the real reliability contract, the less brittle the test will be.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-habit&quot;&gt;A practical review habit&lt;/h3&gt;
&lt;p&gt;When reading a test name, underline the verb: continues, retries, falls back, persists, publishes, or rejects. Then find the assertion that proves that verb happened.&lt;/p&gt;
&lt;p&gt;If you can only find evidence that something threw, returned, or logged, ask whether the same evidence could appear before the named behaviour occurred.&lt;/p&gt;
&lt;p&gt;Green is not the standard. Discrimination is. A useful test must distinguish the system we intended from a nearby broken version.&lt;/p&gt;
&lt;p&gt;For fault-tolerance tests in particular, do not merely observe the aftermath of a failure. Prove that the system crossed the boundary it promised to survive.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31018/a-passing-test-can-still-prove-nothing</guid>
<pubDate>Wed, 14 Oct 2026 06:12:33 +0000</pubDate>
</item>
<item>
<title>Tuesday Coding Tip 26 — JSON (de)serialization in C#</title>
<link>https://coderlegion.com/29761/tuesday-coding-tip-26-json-de-serialization-in-c</link>
<description>&lt;p&gt;Tuesday coding tips are super short posts about various tidbits mainly from C++, but also from other programming languages I use. You can also follow the #TuesdayCodingTips hashtag on Mastodon and Linkedin.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In need of a JSON (de)serializer in C#? One option could be to use the built-in &lt;code&gt;System.Text.Json&lt;/code&gt; functionality. Probably the most popular way is to use the &lt;code&gt;Newtonsoft.Json&lt;/code&gt; library which has a richer feature set while maintaining overall simplicity.&lt;/p&gt;
&lt;p&gt;Things are as simple as having a class with a bunch of public attributes and calling serialize on it. Those attributes don’t even have to be trivial types — anything from &lt;code&gt;DateTime&lt;/code&gt; to &lt;code&gt;HashSet&lt;/code&gt; can be easily handled.&lt;/p&gt;
&lt;p&gt;Deserialization is similarly easy — if a key is present, it will be deserialized. If not, the default value for the attribute will be picked instead.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using Newtonsoft.Json;

namespace NetSandbox;

public class ExtendedValues
{
    public long ErrorCode { get; set; }

    public string Message { get; set; } = string.Empty;
};

public class Property
{
    public string Value { get; set; } = &quot;&quot;;
};

public class Protocol
{
    public long Version { get; set; } = -1;

    [JsonProperty(&quot;extended&quot;, NullValueHandling = NullValueHandling.Ignore)]
    public ExtendedValues? ExtendedValues { get; set; } = default;


    [JsonProperty(&quot;machineGuid&quot;)]
    public Guid MachineGuid { get; set; }

    public List&amp;lt;Property&amp;gt; Properties { get; set; } = new List&amp;lt;Property&amp;gt;();
};

public static class Program
{
    public static void Main()
    {
        Protocol schema = new()
        {
            Version = 1,
            MachineGuid = Guid.NewGuid(),
            Properties = new List&amp;lt;Property&amp;gt; {
                new Property {
                    Value = &quot;hello&quot;
                },
                new Property {
                    Value = &quot;world&quot;
                }
            }
        };

        var json = JsonConvert.SerializeObject(schema);
        // {&quot;Version&quot;:1,&quot;machineGuid&quot;:&quot;156717fd-0904-47af-bcb8-ad74d23b9cc7&quot;,&quot;Properties&quot;:[{&quot;Value&quot;:&quot;hello&quot;},{&quot;Value&quot;:&quot;world&quot;}]}

        var obj = JsonConvert.DeserializeObject&amp;lt;Protocol&amp;gt;(
            &quot;&quot;&quot;{&quot;machineGuid&quot;: &quot;156717fd-0904-47af-bcb8-ad74d23b9cc7&quot;}&quot;&quot;&quot;);
        // obj.Version contains -1
        // obj.MachineGuid containts &quot;156717fd-0904-47af-bcb8-ad74d23b9cc7&quot;
        // obj.ExtendedValues is null
        // obj.Properties is empty list
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need to, you can use annotations to for example customize the name of the serialized property or whether it will be serialized when it is null.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extra thought:&lt;/strong&gt; I originally wrote this post in late 2023. Since then, many people would probably advice you &lt;code&gt;System.Text.Json&lt;/code&gt; on the merit of being faster and getting more and more on-part with Newtonsoft on each major release. Since we did a switch to it some time ago, I cannot wholeheartedly recommend it - it is great for simple use cases, but once you have some data structure that evolved over time and you need to be lenient of what you accept over wire and strict in what you send, working with &lt;code&gt;System.Text.Json&lt;/code&gt; is a bigger pain, especially around &lt;code&gt;NullValueHandling&lt;/code&gt; and &lt;code&gt;DefaultValueHandling&lt;/code&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29761/tuesday-coding-tip-26-json-de-serialization-in-c</guid>
<pubDate>Tue, 13 Oct 2026 09:59:00 +0000</pubDate>
</item>
<item>
<title>A Route Is Not a Feature Until Someone Can Reach It</title>
<link>https://coderlegion.com/30901/a-route-is-not-a-feature-until-someone-can-reach-it</link>
<description>&lt;p&gt;Modern applications are often assembled from modules. That is good for ownership, deployment boundaries, and keeping large codebases understandable. It also creates a subtle user-experience failure mode: a page can compile, carry a valid route, pass its local tests, and still be effectively absent because no one can discover it from a real entry point.&lt;/p&gt;
&lt;p&gt;This is not primarily a visual-design problem. It is an architecture-contract problem.&lt;/p&gt;
&lt;h3 id=&quot;routable-is-not-reachable&quot;&gt;Routable is not reachable&lt;/h3&gt;
&lt;p&gt;Imagine an administrative application with pages spread across two modules. The team already has a test that scans routed pages and checks that each one appears in a navigation catalogue. The test is green, so the navigation surface looks covered.&lt;/p&gt;
&lt;p&gt;Later, someone discovers that the scan only walks one module. The second route tree was outside its boundary. Pages there could be added indefinitely without the test knowing they existed.&lt;/p&gt;
&lt;p&gt;That is the first important lesson: a completeness check is only as complete as its discovery mechanism.&lt;/p&gt;
&lt;p&gt;When the scan is expanded, it finds an entry screen that has documentation and valid follow-on steps, but no link from the application itself. The workflow exists technically. Operationally, it begins with “type this URL.” That is not a usable feature contract.&lt;/p&gt;
&lt;h3 id=&quot;define-a-genuine-entry-point&quot;&gt;Define a genuine entry point&lt;/h3&gt;
&lt;p&gt;The tempting fix is to accept any reference to the route. Perhaps another page links to it. Perhaps it appears on a tab rail. Perhaps a help article mentions it.&lt;/p&gt;
&lt;p&gt;Those references are not equivalent.&lt;/p&gt;
&lt;p&gt;A help article explains a feature; it is not application navigation. A link on a neighbouring screen can be useful, but it may make discoverability depend on an accidental journey. A tab rail is particularly deceptive: it helps someone move within an area only after they have reached one of its pages. It cannot prove that the area itself has an entrance.&lt;/p&gt;
&lt;p&gt;The architectural rule should therefore name the user-facing property you actually want. For example:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Every static administrative workflow page must be represented by a discoverable entry in the primary hub available to its entitled audience.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sentence is stricter than “the route is mentioned somewhere,” and that is precisely why it is useful.&lt;/p&gt;
&lt;h3 id=&quot;test-pages-not-strings&quot;&gt;Test pages, not strings&lt;/h3&gt;
&lt;p&gt;Route graphs have legitimate shapes that a simple string search will misclassify.&lt;/p&gt;
&lt;p&gt;A parameterised detail page is usually reached from a list or search result. Giving every detail route a top-level card would be noisy and misleading. The test can classify routes containing parameters as downstream pages rather than entry pages.&lt;/p&gt;
&lt;p&gt;The reverse case also matters. One page may answer on two route aliases for compatibility. Requiring two navigation cards would duplicate the same destination. The unit of the contract is the page, with at least one discoverable canonical route—not every literal route declaration.&lt;/p&gt;
&lt;p&gt;These distinctions keep the test strict without making it simplistic.&lt;/p&gt;
&lt;h3 id=&quot;make-the-scan-fail-honestly&quot;&gt;Make the scan fail honestly&lt;/h3&gt;
&lt;p&gt;Source-scanning tests have a dangerous failure mode: they can pass after discovering nothing. A changed directory, glob, parser, or build layout can turn the result set into an empty collection, after which every “all pages are covered” assertion becomes vacuously true.&lt;/p&gt;
&lt;p&gt;Add a coarse lower bound to the discovered page count. It does not need to encode the exact inventory. It only needs to make a broken scan conspicuous. Pair that with a useful failure message that lists uncovered entry pages and explains the intended remedy.&lt;/p&gt;
&lt;p&gt;Exceptions deserve the same discipline. Sometimes an exception is legitimate, but an exemption list easily becomes a hiding place. Store a reason beside each exception, keep the list narrow, and assert that every exception still corresponds to a real uncovered case. Once navigation is fixed, the stale exception should fail the next test run.&lt;/p&gt;
&lt;p&gt;That turns exceptions into temporary, reviewable decisions rather than permanent silence.&lt;/p&gt;
&lt;h3 id=&quot;align-visibility-with-authorization&quot;&gt;Align visibility with authorization&lt;/h3&gt;
&lt;p&gt;Navigation and authorization answer related but different questions.&lt;/p&gt;
&lt;p&gt;Navigation visibility asks, “Should this person be offered this destination?” Authorization asks, “May this request proceed?” A hidden card is not a security boundary. Someone can still follow a saved link or construct a request directly.&lt;/p&gt;
&lt;p&gt;The useful contract is alignment: the navigation entry should use the same entitlement expressed by the destination policy, while the page or endpoint continues to enforce that policy independently.&lt;/p&gt;
&lt;p&gt;This check often exposes ambiguity. If a destination has only broad authentication while its navigation card is restricted to a privileged audience, do not quietly declare the card to be protection. Flag the mismatch for an explicit security decision. Navigation work should reveal authorization uncertainty, not accidentally resolve it.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real-2&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;A strict hub contract has costs. The catalogue grows. New pages require deliberate placement and wording. A hub can become crowded if every capability is presented at the same level.&lt;/p&gt;
&lt;p&gt;The alternative, however, is not free. A loose rule produces accidental journeys, invisible capabilities, and tests that certify the presence of links without certifying usable discovery.&lt;/p&gt;
&lt;p&gt;The practical balance is to keep the entry-point contract strict while allowing the interface to group, search, filter, and progressively disclose destinations. The architecture test should protect reachability; the visual design can protect clarity.&lt;/p&gt;
&lt;h3 id=&quot;a-small-checklist&quot;&gt;A small checklist&lt;/h3&gt;
&lt;p&gt;For a modular routed application:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory every route tree, not only the first one the test was written for.&lt;/li&gt;
&lt;li&gt;Classify static entry pages, parameterised downstream pages, and route aliases.&lt;/li&gt;
&lt;li&gt;Identify the navigation roots users can actually reach.&lt;/li&gt;
&lt;li&gt;Require every entry page to appear from an appropriate root.&lt;/li&gt;
&lt;li&gt;Add a minimum discovery count to catch broken scans.&lt;/li&gt;
&lt;li&gt;Make exemptions explicit, reasoned, narrow, and self-cleaning.&lt;/li&gt;
&lt;li&gt;Align link visibility with destination authorization, while enforcing security at the destination.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The deeper lesson is that architecture includes the path a person can take through the system. If a feature has no honest entrance, its route is an implementation detail—not a delivered capability.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30901/a-route-is-not-a-feature-until-someone-can-reach-it</guid>
<pubDate>Tue, 13 Oct 2026 07:01:11 +0000</pubDate>
</item>
<item>
<title>Don’t Turn Delegated Access Into an Identity Oracle</title>
<link>https://coderlegion.com/30769/dont-turn-delegated-access-into-an-identity-oracle</link>
<description>&lt;p&gt;Delegated access often starts with an innocent mismatch.&lt;/p&gt;
&lt;p&gt;A person knows an email address. The authorization system knows an account identifier. The obvious bridge is a search box: enter the address, find the account, then grant access.&lt;/p&gt;
&lt;p&gt;That bridge can become a privacy leak.&lt;/p&gt;
&lt;p&gt;If the screen behaves differently for a known address and an unknown one, every attempt answers a sensitive question: “Does this person have an account here?” An authenticated caller can repeat that question across many addresses. The feature may be correctly authorized for its intended task while still exposing more identity information than the task requires.&lt;/p&gt;
&lt;p&gt;The deeper lesson is simple: account discovery is not a necessary prerequisite for delegated access. Identity can be resolved later, at the moment the recipient proves ownership and gives consent.&lt;/p&gt;
&lt;h3 id=&quot;the-convenient-design-carries-a-hidden-query&quot;&gt;The convenient design carries a hidden query&lt;/h3&gt;
&lt;p&gt;Consider a generic workflow in which one person wants to share a limited capability with another.&lt;/p&gt;
&lt;p&gt;The direct version usually looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enter the recipient’s address.&lt;/li&gt;
&lt;li&gt;Search for a matching account.&lt;/li&gt;
&lt;li&gt;Show whether a match exists.&lt;/li&gt;
&lt;li&gt;Create access against that account.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Authorization to share the resource does not make the lookup harmless. Even subtle differences disclose the answer: different text, a disabled button, a profile preview, an HTTP status, or noticeably different timing.&lt;/p&gt;
&lt;p&gt;This is a useful design-review question: what does the caller need to accomplish, and what does the interface reveal merely because it is convenient?&lt;/p&gt;
&lt;h3 id=&quot;move-identity-resolution-to-acceptance&quot;&gt;Move identity resolution to acceptance&lt;/h3&gt;
&lt;p&gt;An invitation workflow changes the order of proof.&lt;/p&gt;
&lt;p&gt;The initiator provides an address and the intended scope. The system records an invitation and returns the same public outcome whether the address currently belongs to an account or not. Nothing attempts to answer the account-existence question.&lt;/p&gt;
&lt;p&gt;Most importantly, the invitation itself grants no access.&lt;/p&gt;
&lt;p&gt;When the recipient follows the invitation, they authenticate. The system compares the account’s confirmed address with the invited address. Only a match may inspect the offer and accept it. The access record is then created against the identity that actually accepted.&lt;/p&gt;
&lt;p&gt;That ordering gives the workflow better semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the initiator names a person in a form they genuinely know;&lt;/li&gt;
&lt;li&gt;the system does not reveal whether an account already exists;&lt;/li&gt;
&lt;li&gt;possession of a link is insufficient on its own;&lt;/li&gt;
&lt;li&gt;the recipient proves control of the invited address;&lt;/li&gt;
&lt;li&gt;access records consent rather than an assumption made by the initiator.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;a-token-is-necessary-but-not-sufficient&quot;&gt;A token is necessary, but not sufficient&lt;/h3&gt;
&lt;p&gt;Invitation links are bearer secrets, so their handling deserves the same care as password-reset links.&lt;/p&gt;
&lt;p&gt;Persist a one-way hash rather than the raw token. Submit the token in a request body when practical so routine URL logging does not repeat it across server, proxy, and browser records.&lt;/p&gt;
&lt;p&gt;Bind the token to the intended identity as well. A forwarded or screenshotted link should not be enough to inspect the offer. Requiring a signed-in account with the matching confirmed address contains that failure mode.&lt;/p&gt;
&lt;p&gt;Resending needs a clear contract: mint a new token and invalidate the old one. Otherwise each resend increases the live secrets while the interface still shows one invitation.&lt;/p&gt;
&lt;p&gt;Finally, build the link from trusted server configuration. Request-controlled host information should not decide where a live token is sent.&lt;/p&gt;
&lt;h3 id=&quot;retry-safety-belongs-in-the-workflow&quot;&gt;Retry safety belongs in the workflow&lt;/h3&gt;
&lt;p&gt;Mobile networks fail at awkward boundaries. The server may commit an invitation and send the message while the response never reaches the device.&lt;/p&gt;
&lt;p&gt;If pressing the button again creates a fresh invitation, one uncertain response can produce two live tokens. The interface may show only one of them.&lt;/p&gt;
&lt;p&gt;A stable command identity lets the server recognize a replay and return the original result without sending again. That identity must describe the request as well as the button press: reusing it with different arguments should be rejected, not silently mapped to an unrelated result.&lt;/p&gt;
&lt;p&gt;Delivery outcomes need honest language. A definite rejection differs from an unknown result after handoff. “Unknown” is safer than encouraging a duplicate.&lt;/p&gt;
&lt;h3 id=&quot;make-consent-and-access-one-atomic-transition&quot;&gt;Make consent and access one atomic transition&lt;/h3&gt;
&lt;p&gt;Acceptance changes two facts: the invitation is consumed, and access is created.&lt;/p&gt;
&lt;p&gt;Persist those facts together.&lt;/p&gt;
&lt;p&gt;If access is written but the invitation remains pending, the token may be accepted again. That can create a second grant that nobody sees. Revoking the visible grant would leave the duplicate active.&lt;/p&gt;
&lt;p&gt;One transaction makes the invariant explicit: there is no state in which acceptance succeeded while the invitation is still redeemable.&lt;/p&gt;
&lt;p&gt;Re-check the initiator’s authority inside this transition too. Authority can change between invitation and acceptance. An invitation should not outlive the permission that allowed it to be created.&lt;/p&gt;
&lt;h3 id=&quot;test-the-negative-space-2&quot;&gt;Test the negative space&lt;/h3&gt;
&lt;p&gt;The most valuable tests are not only happy-path checks. They prove what the workflow refuses to reveal or create:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;known and unknown addresses produce indistinguishable initiator results;&lt;/li&gt;
&lt;li&gt;an invitation alone authorizes nothing;&lt;/li&gt;
&lt;li&gt;a different or unconfirmed account cannot inspect or accept it;&lt;/li&gt;
&lt;li&gt;retrying does not send again;&lt;/li&gt;
&lt;li&gt;resending invalidates the previous token;&lt;/li&gt;
&lt;li&gt;stale initiator authority blocks new access;&lt;/li&gt;
&lt;li&gt;repeated acceptance does not create duplicate grants;&lt;/li&gt;
&lt;li&gt;consumption and access creation succeed or fail together.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-trade-off-is-deliberate-complexity&quot;&gt;The trade-off is deliberate complexity&lt;/h3&gt;
&lt;p&gt;This design adds states, expiry, delivery handling, retry receipts, token rotation, concurrency, and tests. It is unquestionably more work than a directory lookup followed by an insert.&lt;/p&gt;
&lt;p&gt;The added complexity corresponds to uncertainty the simpler design hides. The invitation makes privacy, consent, identity proof, and atomicity explicit.&lt;/p&gt;
&lt;p&gt;The practical takeaway is to inspect every workflow that translates a human-readable identifier into an account. Ask whether the caller truly needs account discovery, or whether identity can be resolved by the recipient at acceptance.&lt;/p&gt;
&lt;p&gt;Often, the safer flow is also the more truthful one.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30769/dont-turn-delegated-access-into-an-identity-oracle</guid>
<pubDate>Mon, 12 Oct 2026 06:01:17 +0000</pubDate>
</item>
<item>
<title>When the Tool Cannot See the Whole Model</title>
<link>https://coderlegion.com/30687/when-the-tool-cannot-see-the-whole-model</link>
<description>&lt;p&gt;Modular architecture creates an uncomfortable class of failure: the application can understand its complete model while design-time tooling sees only a legitimate subset.&lt;/p&gt;
&lt;p&gt;That difference is easy to misread as drift. It is also dangerous to dismiss. The useful engineering move is to identify exactly what the tool cannot observe, then relocate each safety check to a boundary with enough context to answer the question correctly.&lt;/p&gt;
&lt;h3 id=&quot;the-warning-was-real-but-its-conclusion-was-wrong&quot;&gt;The warning was real, but its conclusion was wrong&lt;/h3&gt;
&lt;p&gt;Consider a shared EF Core context that can be extended by optional runtime modules. Each module contributes mappings without forcing the lower-level persistence package to reference every higher-level feature package. That dependency direction is valuable: the shared module remains reusable, and optional features remain optional.&lt;/p&gt;
&lt;p&gt;At runtime, the host loads the contributing modules and the complete model is assembled. At design time, the tooling process starts from a narrower package. Loading one contributor directly would create a dependency cycle, so that contributor is absent.&lt;/p&gt;
&lt;p&gt;The migration snapshot still represents the complete model. The design-time model does not. A comparison therefore reports a difference and may propose removing an object that is not obsolete at all; it is merely invisible from that process.&lt;/p&gt;
&lt;p&gt;The warning accurately says, “these two views differ.” It cannot establish why they differ.&lt;/p&gt;
&lt;h3 id=&quot;separate-database-update-from-migration-authoring&quot;&gt;Separate database update from migration authoring&lt;/h3&gt;
&lt;p&gt;The first mistake would be treating every operation as though it asks the same question.&lt;/p&gt;
&lt;p&gt;A database update applies migrations that have already passed review and validation. If a known model subset causes a framework warning to block that operation, a narrowly scoped suppression can be reasonable. The important word is narrowly. The exception belongs in the design-time path that has the structural blind spot, with an explanation of why the complete model cannot be assembled there.&lt;/p&gt;
&lt;p&gt;Migration authoring is different. A scaffolder turns its limited view into proposed schema changes. If it cannot see an extension-owned table, a removal operation may look perfectly logical. Suppressing a warning does nothing to make that proposal safe.&lt;/p&gt;
&lt;p&gt;So the second boundary needs a stronger invariant: inspect the generated migration operations and fail if they remove, rename, or destructively alter protected extension data. That converts a warning in prose into an executable gate.&lt;/p&gt;
&lt;p&gt;This distinction matters. “Allow the known update path” and “trust every future generated migration” are not the same decision.&lt;/p&gt;
&lt;h3 id=&quot;keep-a-complete-model-drift-check&quot;&gt;Keep a complete-model drift check&lt;/h3&gt;
&lt;p&gt;Once a noisy comparison is suppressed, the obvious risk is hiding real drift. The answer is not to pretend the partial view became authoritative. Keep a separate test that runs in a host where all contributors are loaded, builds the complete runtime model, and compares that model with the snapshot.&lt;/p&gt;
&lt;p&gt;Now each check has one clear responsibility:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the design-time update path tolerates the known subset;&lt;/li&gt;
&lt;li&gt;the full-model test detects genuine model-to-snapshot drift;&lt;/li&gt;
&lt;li&gt;the migration-operation test blocks destructive scaffolding.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is more precise than relying on one framework signal for three different questions.&lt;/p&gt;
&lt;h3 id=&quot;prove-the-guard-is-not-vacuously-green&quot;&gt;Prove the guard is not vacuously green&lt;/h3&gt;
&lt;p&gt;Negative tests deserve a positive control.&lt;/p&gt;
&lt;p&gt;Imagine a test that scans every migration and asserts that none removes a protected table. It passes. Good news, perhaps. But it also passes if migration discovery silently returns an empty collection.&lt;/p&gt;
&lt;p&gt;Add a companion assertion that the scan finds the migration that originally created the protected object. If discovery breaks, the positive control fails. The pair proves both that the dangerous operation is absent and that the evidence set is present.&lt;/p&gt;
&lt;p&gt;This pattern generalizes beyond migrations. Any test that says “no forbidden item exists” should make you ask whether it would still pass when the scanner found nothing.&lt;/p&gt;
&lt;h3 id=&quot;shared-process-state-is-another-hidden-boundary&quot;&gt;Shared process state is another hidden boundary&lt;/h3&gt;
&lt;p&gt;The same change exposed a smaller reliability lesson. Tests for design-time factories temporarily set a process-wide configuration value. Separate test classes ran concurrently, and one could restore the value while another was still using it. Each class passed alone; together they failed intermittently.&lt;/p&gt;
&lt;p&gt;The repair was not another retry. The tests were placed in one non-parallel collection because they share mutable process state.&lt;/p&gt;
&lt;p&gt;That is the same ownership principle in miniature: the scope of the resource determines the scope of coordination. A process-wide variable cannot safely be treated as test-local state.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-explicit-complexity&quot;&gt;The trade-off is explicit complexity&lt;/h3&gt;
&lt;p&gt;This approach is not free. There is now a documented exception, a complete-model drift test, a migration-operation guard, a positive control, and serialized tests around shared environment state. Future maintainers must understand why those pieces exist.&lt;/p&gt;
&lt;p&gt;But the alternative is false simplicity: a generic warning that blocks legitimate work, generated operations that can target valid data, and flaky tests that disguise the real contract.&lt;/p&gt;
&lt;p&gt;The practical rule I keep is this:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;When tooling has a structural blind spot, narrow the exception and move safety to executable checks that can observe the complete truth.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Do not silence evidence broadly. Do not force an architectural dependency merely to satisfy a tool. Give each check the context it needs, and make dangerous assumptions fail before they become database changes.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30687/when-the-tool-cannot-see-the-whole-model</guid>
<pubDate>Sun, 11 Oct 2026 06:55:17 +0000</pubDate>
</item>
<item>
<title>The Weekend After: HeadlessAi Has Entered the Workshop</title>
<link>https://coderlegion.com/30562/the-weekend-after-headlessai-has-entered-the-workshop</link>
<description>&lt;p&gt;Last weekend, we introduced some of the toys we're building at The Singularity Workshop. This weekend, we've got more!  (All are c#)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.HeadlessAi&quot;&gt;HeadlessAi&lt;/a&gt;&lt;/strong&gt; — Connecting .NET applications to AI endpoints through a straightforward, extensible interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/Profiles&quot;&gt;Profiles&lt;/a&gt;&lt;/strong&gt; — Exploring identity, relationships, privacy, and user-controlled data sharing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.Ontology&quot;&gt;Ontology&lt;/a&gt;&lt;/strong&gt; — Organizing information in flexible, meaningful structures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.MicroBundleIngestor&quot;&gt;MicroBundleIngestor&lt;/a&gt;&lt;/strong&gt; — Exploring new ways to bring MicroBundles into the ecosystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.Economy&quot;&gt;Economy&lt;/a&gt;&lt;/strong&gt; — Beginning to explore the foundations of an interconnected digital economy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;So, what makes the Workshop different?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We don't just build packages and leave you to figure them out. We explain the &lt;em&gt;what, why, and how&lt;/em&gt; behind them—from the underlying theory to practical examples you can learn from and build upon.&lt;/p&gt;
&lt;p&gt;You don't have to be an expert programmer to start exploring. We're building an ecosystem designed to help you understand the technology, connect the pieces, and bring your own ideas to life.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't just use the tools. Learn to build with them.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Come explore: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest&quot;&gt;The Singularity Workshop on GitHub&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30562/the-weekend-after-headlessai-has-entered-the-workshop</guid>
<pubDate>Sat, 10 Oct 2026 06:54:10 +0000</pubDate>
</item>
<item>
<title>The Async Result Belongs to the Snapshot That Started It</title>
<link>https://coderlegion.com/30557/the-async-result-belongs-to-the-snapshot-that-started-it</link>
<description>&lt;p&gt;Modern client components are long-lived, reactive, and full of asynchronous work. That combination creates a subtle class of defect: a response can be factually correct for the request that produced it, yet wrong for the screen that exists when it arrives.&lt;/p&gt;
&lt;p&gt;This is especially important when a component mixes route parameters with identity-scoped state. The user can navigate while a request is in flight. Authentication can resolve, refresh, or change. The component instance may survive both events. Unless the component tracks which context owns each result, an older task can update a newer view.&lt;/p&gt;
&lt;p&gt;The core rule is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;An awaited result belongs to the input snapshot that started it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That snapshot is more than a component reference. It can include the current route value, the current authentication-state operation, and a monotonically increasing generation number.&lt;/p&gt;
&lt;h3 id=&quot;how-the-race-forms&quot;&gt;How the race forms&lt;/h3&gt;
&lt;p&gt;Imagine a detail page that loads an item, then asks whether the current viewer has reacted to it. The page shows a highlighted action when the answer is yes.&lt;/p&gt;
&lt;p&gt;The component begins loading item A for identity A. Before the viewer-specific request returns, the user signs out and another identity becomes current—or the same component navigates to item B. The old request then completes with a truthful answer: identity A did react to item A.&lt;/p&gt;
&lt;p&gt;If the continuation writes directly to a shared &lt;code&gt;isActive&lt;/code&gt; field, that truth is now displayed in the wrong context. Item B may appear active, or identity B may see state derived from identity A.&lt;/p&gt;
&lt;p&gt;This is not merely a cosmetic race. Identity-derived state can influence which actions are enabled, what ownership badges appear, and which command a user can submit next.&lt;/p&gt;
&lt;h3 id=&quot;make-ownership-explicit&quot;&gt;Make ownership explicit&lt;/h3&gt;
&lt;p&gt;A practical pattern is to treat every meaningful input change as a new generation.&lt;/p&gt;
&lt;p&gt;When a route or authentication input changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Increment the generation.&lt;/li&gt;
&lt;li&gt;Capture the route and authentication input in local variables.&lt;/li&gt;
&lt;li&gt;Clear state derived from the previous identity.&lt;/li&gt;
&lt;li&gt;Disable identity-scoped actions while the new identity is unresolved.&lt;/li&gt;
&lt;li&gt;After each &lt;code&gt;await&lt;/code&gt;, confirm that the generation and captured inputs are still current.&lt;/li&gt;
&lt;li&gt;Apply results only when those checks pass.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The checks matter after every awaited boundary, not only at the end. A component may load the main item, related content, entitlement state, reaction state, and cart state in sequence. Any await is a point where navigation or identity can change.&lt;/p&gt;
&lt;p&gt;Cancellation tokens can reduce wasted work, and they are worth using when the underlying operation supports cancellation. But cancellation alone is not the ownership proof. Cancellation can race with completion, and some dependencies cannot cancel promptly. The continuation still needs to ask, “Does this result belong to the current snapshot?”&lt;/p&gt;
&lt;h3 id=&quot;fail-closed-while-identity-is-unknown&quot;&gt;Fail closed while identity is unknown&lt;/h3&gt;
&lt;p&gt;There is another important state between old identity and new identity: unresolved identity.&lt;/p&gt;
&lt;p&gt;Keeping the old viewer-specific UI visible during that interval is convenient, but unsafe. The component should clear ownership badges, active reactions, and similar derived state before awaiting the new authentication result. Any action that depends on identity should be disabled until the new snapshot is ready.&lt;/p&gt;
&lt;p&gt;That brief disabled state is honest. The application does not yet know which identity owns the next command.&lt;/p&gt;
&lt;p&gt;This principle also prevents an action from being sent using newly refreshed credentials while the visible UI still describes the previous viewer. Read state and write authority move together.&lt;/p&gt;
&lt;h3 id=&quot;test-the-interleaving-not-only-the-outcome&quot;&gt;Test the interleaving, not only the outcome&lt;/h3&gt;
&lt;p&gt;Ordinary happy-path tests rarely expose this defect because requests finish in the same order they started.&lt;/p&gt;
&lt;p&gt;A stronger component test controls the schedule:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start a viewer-specific read and hold its response.&lt;/li&gt;
&lt;li&gt;Change the route or authentication identity.&lt;/li&gt;
&lt;li&gt;Wait for the new screen to settle.&lt;/li&gt;
&lt;li&gt;Release the old response.&lt;/li&gt;
&lt;li&gt;Assert that the current screen does not adopt the old result.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A separate test should hold authentication unresolved and verify that identity-scoped actions remain disabled. This proves that the UI fails closed during the transition.&lt;/p&gt;
&lt;p&gt;There is also a useful counter-test: rerender with exactly the same inputs while a valid request is pending. The component should not discard or duplicate work merely because the framework rendered again. The boundary is a meaningful input change, not every render cycle.&lt;/p&gt;
&lt;p&gt;These tests are valuable because they verify time and ownership, not just a final value. They turn a race that might appear once in a thousand interactions into a deterministic scenario.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off&quot;&gt;The trade-off&lt;/h3&gt;
&lt;p&gt;Generation markers, captured snapshots, and repeated currentness checks add code. Temporarily disabling an action can introduce a small pause. Overusing the pattern can also make a component difficult to read.&lt;/p&gt;
&lt;p&gt;The answer is not to attach a counter to every asynchronous line. Use the pattern where results are scoped to changing inputs: routes, selected records, authentication identity, workspace, locale, or another boundary that can change while the component remains mounted. Extract a small helper when the same ownership rule appears repeatedly.&lt;/p&gt;
&lt;p&gt;The payoff is larger than visual correctness. Explicit async ownership reduces stale-route updates, prevents state from crossing identity boundaries, and stops commands from being enabled under an unresolved context.&lt;/p&gt;
&lt;p&gt;The humble takeaway is this: asynchronous correctness is not only about receiving the right answer. It is also about proving that the answer still belongs here.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30557/the-async-result-belongs-to-the-snapshot-that-started-it</guid>
<pubDate>Sat, 10 Oct 2026 06:01:28 +0000</pubDate>
</item>
<item>
<title>Building Reliable Backend Applications with C# and .NET</title>
<link>https://coderlegion.com/29925/building-reliable-backend-applications-with-c-and-net</link>
<description>&lt;p&gt;Backend development is more than writing code that works. For me, it is about building systems that are &lt;strong&gt;reliable, maintainable, secure, and capable of supporting real business needs&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;My main experience is with &lt;strong&gt;C#, .NET, ASP.NET Core, SQL Server, and Entity Framework&lt;/strong&gt;. I enjoy working on backend systems where there are real requirements to understand, databases to design, APIs to build, and performance problems to solve.&lt;/p&gt;
&lt;h4 id=&quot;turning-business-requirements-into-backend-solutions&quot;&gt;Turning Business Requirements into Backend Solutions&lt;/h4&gt;
&lt;p&gt;A typical project starts with understanding what the business actually needs. Before writing code, I try to understand the workflow, the data involved, and how different parts of the application will communicate.&lt;/p&gt;
&lt;p&gt;Once the requirements are clear, I focus on creating a backend structure that is easy to maintain and can grow with the application.&lt;/p&gt;
&lt;p&gt;This usually involves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Designing backend architecture&lt;/li&gt;
&lt;li&gt;Developing APIs with ASP.NET Core&lt;/li&gt;
&lt;li&gt;Working with SQL Server databases&lt;/li&gt;
&lt;li&gt;Creating data models with Entity Framework&lt;/li&gt;
&lt;li&gt;Implementing authentication and authorization&lt;/li&gt;
&lt;li&gt;Writing business logic in C#&lt;/li&gt;
&lt;li&gt;Improving database queries and application performance&lt;/li&gt;
&lt;li&gt;Debugging and maintaining existing applications&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;working-with-databases&quot;&gt;Working with Databases&lt;/h4&gt;
&lt;p&gt;A backend application is only as good as the data layer behind it.&lt;/p&gt;
&lt;p&gt;I have worked extensively with &lt;strong&gt;SQL Server and Entity Framework&lt;/strong&gt;, including database design, relationships, queries, stored procedures, migrations, and performance optimization.&lt;/p&gt;
&lt;p&gt;One thing I have learned is that database performance should not be treated as an afterthought. A poorly designed query or database structure can affect the entire application.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Efficient database design and optimized queries can make a huge difference in application performance.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;building-secure-apis&quot;&gt;Building Secure APIs&lt;/h4&gt;
&lt;p&gt;Modern applications often depend heavily on APIs. I focus on creating APIs that are organized, predictable, and secure.&lt;/p&gt;
&lt;p&gt;From handling requests and validation to authentication, authorization, error handling, and database communication, every layer needs to work together properly.&lt;/p&gt;
&lt;p&gt;I also believe that an API should be designed with the consumers in mind. Clear responses, consistent error handling, and proper validation make applications much easier to maintain.&lt;/p&gt;
&lt;h4 id=&quot;performance-and-problem-solving&quot;&gt;Performance and Problem Solving&lt;/h4&gt;
&lt;p&gt;Backend development often involves finding problems that are not immediately obvious.&lt;/p&gt;
&lt;p&gt;An application may work correctly but become slow when the amount of data increases. A database query may work with a few records but become inefficient with thousands or millions of records.&lt;/p&gt;
&lt;p&gt;That is where debugging and performance analysis become important.&lt;/p&gt;
&lt;p&gt;I enjoy investigating these types of problems, finding the root cause, and improving the implementation rather than simply applying a temporary fix.&lt;/p&gt;
&lt;h4 id=&quot;maintaining-existing-applications&quot;&gt;Maintaining Existing Applications&lt;/h4&gt;
&lt;p&gt;Not every project starts from scratch.&lt;/p&gt;
&lt;p&gt;A large part of backend development can involve working with existing applications, understanding someone else's code, fixing bugs, improving functionality, and gradually making the system better.&lt;/p&gt;
&lt;p&gt;This requires patience and the ability to understand existing architecture before making changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Good backend development is not always about rewriting code. Sometimes the best solution is improving what already exists.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;continuous-learning&quot;&gt;Continuous Learning&lt;/h4&gt;
&lt;p&gt;Technology keeps changing, so there is always something new to learn.&lt;/p&gt;
&lt;p&gt;Even while working primarily with C# and .NET, I continue exploring better development practices, modern backend architecture, API design, database optimization, and new tools that can improve the development process.&lt;/p&gt;
&lt;p&gt;For me, programming is a continuous process of learning, building, debugging, and improving.&lt;/p&gt;
&lt;p&gt;I enjoy taking a business requirement, breaking it down into smaller technical problems, and eventually turning those ideas into a backend system that people can actually use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always learning. Always building. Always looking for a better way to solve the problem.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29925/building-reliable-backend-applications-with-c-and-net</guid>
<pubDate>Tue, 06 Oct 2026 02:40:20 +0000</pubDate>
</item>
<item>
<title>It’s the Weekend. The Workshop Has Toys. Come Play.</title>
<link>https://coderlegion.com/29600/its-the-weekend-the-workshop-has-toys-come-play</link>
<description>&lt;p&gt;It’s the weekend.&lt;/p&gt;
&lt;p&gt;Instead of writing another 2,000-word manifesto on what The Singularity Workshop &lt;em&gt;might&lt;/em&gt; become someday, I’m just going to open the toy box.&lt;/p&gt;
&lt;p&gt;I’ve been building.&lt;/p&gt;
&lt;p&gt;Some of it is foundational. Some of it is highly experimental. Some of it is definitely still alpha software. But enough of it has crossed the line from &lt;strong&gt;“whiteboard diagram”&lt;/strong&gt; to &lt;strong&gt;running code&lt;/strong&gt; that it’s time to put it out in public.&lt;/p&gt;
&lt;p&gt;You can download it, read the source, compile it, and—most importantly—&lt;strong&gt;break it&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;where-to-start-nuget&quot;&gt;Where to Start: NuGet&lt;/h3&gt;
&lt;p&gt;The easiest way to see what is actually breathing right now is NuGet.&lt;/p&gt;
&lt;p&gt;Search for &lt;strong&gt;TheSingularityWorkshop&lt;/strong&gt; and make sure &lt;strong&gt;Include prerelease&lt;/strong&gt; is enabled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages?q=TheSingularityWorkshop&amp;amp;prerelease=true&quot;&gt;Search The Singularity Workshop packages on NuGet&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most of what I'm pushing right now is deliberately prerelease.&lt;/p&gt;
&lt;p&gt;These aren't polished commercial products with a marketing department hiding the machinery behind a landing page. They are architectural experiments that have finally become real enough for someone else to get their hands dirty with.&lt;/p&gt;
&lt;p&gt;That is exactly the point.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-toy-box&quot;&gt;The Toy Box&lt;/h2&gt;
&lt;p&gt;Here is what is currently sitting on the workbench.&lt;/p&gt;
&lt;h3 id=&quot;fsm-api&quot;&gt;FSM_API&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&quot;&gt;NuGet&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/FSM_API&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the bedrock.&lt;/p&gt;
&lt;p&gt;The core premise is that behavior can be represented explicitly as state, allowing state-driven behavior to remain separate from the data objects it operates on.&lt;/p&gt;
&lt;p&gt;FSM_API is a framework-agnostic C# finite-state-machine system with explicit state definitions, transitions, contexts, processing groups, lifecycle handling, and runtime behavior.&lt;/p&gt;
&lt;p&gt;If you want to start at the absolute beginning of the Workshop, start here.&lt;/p&gt;
&lt;p&gt;This is where the behavior machinery begins.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fsm-cos&quot;&gt;FSM_COS&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_COS&quot;&gt;NuGet&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.FSM_COS&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is where the pieces start becoming a system.&lt;/p&gt;
&lt;p&gt;FSM_COS is the &lt;strong&gt;composition kernel&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A runtime manifest says what is requested. FSM_COS resolves the MicroBundle dependency closure, propagates configuration, loads the required capabilities, arbitrates them toward convergence, and produces a &lt;code&gt;RuntimeAssembly&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Runtime Manifest
       |
       v
    FSM_COS
       |
       +-- resolve MicroBundles
       +-- resolve dependencies
       +-- propagate configuration
       +-- load/install
       +-- arbitrate
       +-- converge
       |
       v
RuntimeAssembly
       |
       v
host / manifestation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is what FSM_COS &lt;strong&gt;doesn't&lt;/strong&gt; do.&lt;/p&gt;
&lt;p&gt;It doesn't become the application.&lt;/p&gt;
&lt;p&gt;It doesn't become the GUI.&lt;/p&gt;
&lt;p&gt;It doesn't become the storage system.&lt;/p&gt;
&lt;p&gt;It doesn't become the Experience.&lt;/p&gt;
&lt;p&gt;It assembles the machine and hands the resulting composition to something else.&lt;/p&gt;
&lt;p&gt;The crane doesn't become the machine.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;microbundledomain&quot;&gt;MicroBundleDomain&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.MicroBundleDomain&quot;&gt;NuGet&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.MicroBundleDomain&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This one is particularly important to the architecture.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MicroBundleDomain&lt;/code&gt; is a &lt;strong&gt;neutral contract for independently authored capabilities&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A MicroBundle is a focused unit of capability that can identify itself, declare dependencies and providers, accept configuration, enter a runtime, and participate in composition without requiring the host to understand its private domain.&lt;/p&gt;
&lt;p&gt;The important architectural separation is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;What is this capability?
        |
        v
MicroBundleDomain

Where do I get it?
        |
        v
MicroBundleRepository

How do I compose it?
        |
        v
FSM_COS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That means &lt;code&gt;MicroBundleDomain&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; belong underneath FSM_COS as an implementation dependency.&lt;/p&gt;
&lt;p&gt;It is the neutral seam.&lt;/p&gt;
&lt;p&gt;That matters because someone should be able to author a capability without permanently welding their package to my composition engine.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;microbundlerepository&quot;&gt;MicroBundleRepository&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.MicroBundleRepository&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If MicroBundleDomain answers &lt;strong&gt;what&lt;/strong&gt;, the Repository answers &lt;strong&gt;where&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The repository explores durable delivery of versioned MicroBundle artifacts, currently using Azure Blob Storage as the storage substrate.&lt;/p&gt;
&lt;p&gt;The distinction is deliberately sharp:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Blob Storage
    = substrate

Repository
    = artifact delivery boundary

FSM_COS
    = composition boundary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Artifacts have deterministic identity based on the MicroBundle ID, explicit version, and SHA-256 content identity.&lt;/p&gt;
&lt;p&gt;The repository doesn't become a database.&lt;/p&gt;
&lt;p&gt;It doesn't arbitrate.&lt;/p&gt;
&lt;p&gt;It doesn't compose Experiences.&lt;/p&gt;
&lt;p&gt;It doesn't become a GUI.&lt;/p&gt;
&lt;p&gt;It answers a much smaller question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Given this artifact address, where are the bytes and can I verify them?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I am having entirely too much fun ruthlessly separating these concerns.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gui&quot;&gt;GUI&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.GUI&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is a platform-neutral GUI core in here, alongside manifestations for &lt;strong&gt;Blazor and WPF&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;But the real experiment isn't drawing buttons.&lt;/p&gt;
&lt;p&gt;It's asking:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Can an object describe itself structurally well enough that a generic GUI can just render it?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That led to a reflection-driven GUI architecture where the domain model doesn't need to know about the editor that happens to display it.&lt;/p&gt;
&lt;p&gt;The basic idea is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Domain model
     |
     | describes itself
     v
Reflection / semantic projection
     |
     v
GUI.Core
     |
     +--------+--------+
     v                 v
  Blazor              WPF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So if you're interested in semantic interface construction, reflection, recursive GUI structures, and separating &lt;strong&gt;what something means&lt;/strong&gt; from &lt;strong&gt;how it looks&lt;/strong&gt;, poke around in this one.&lt;/p&gt;
&lt;p&gt;There is considerably more going on here than “make a button.”&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fsm-serialization&quot;&gt;FSM_Serialization&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_Serialization&quot;&gt;NuGet&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.FSM_Serialization&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This isn't an experiment in shouting &lt;em&gt;“my serializer is the fastest.”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It's an experiment in &lt;strong&gt;strict ownership&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The domain owns what its data means.&lt;/p&gt;
&lt;p&gt;The serialization layer owns how that data crosses a byte boundary.&lt;/p&gt;
&lt;p&gt;The host decides where those bytes ultimately go.&lt;/p&gt;
&lt;p&gt;The package deliberately stays out of databases, filesystems, HTTP, application configuration, object-graph magic, and other concerns that belong somewhere else.&lt;/p&gt;
&lt;p&gt;It's small, tight, and opinionated.&lt;/p&gt;
&lt;p&gt;I wrote a deeper dive on this one:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/6768/i-built-a-serializer-that-refuses-to-own-your-data&quot;&gt;I Built a Serializer That Refuses to Own Your Data&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fsm-rest&quot;&gt;FSM_REST&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.FSM_REST&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This isn't another web framework.&lt;/p&gt;
&lt;p&gt;FSM_REST provides a reusable boundary for describing and composing REST capabilities.&lt;/p&gt;
&lt;p&gt;The interesting part is that a REST capability can be represented as a &lt;strong&gt;capability recipe&lt;/strong&gt; rather than dragging an entire web application into the runtime.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;REST capability
      |
      v
FSM_REST
      |
      +-- capability description
      +-- request construction
      +-- transport boundary
      |
      v
remote API
      |
      v
current data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The MicroBundle can describe what is needed to obtain the capability without pretending that the remote API's current response data belongs inside the bundle.&lt;/p&gt;
&lt;p&gt;That distinction becomes increasingly useful when capabilities are composed dynamically.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;protocolai-amp-grammarai&quot;&gt;ProtocolAI &amp;amp; GrammarAI&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ProtocolAI: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.ProtocolAi&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;GrammarAI: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.GrammarAi&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These sound like AI wrappers.&lt;/p&gt;
&lt;p&gt;They aren't.&lt;/p&gt;
&lt;p&gt;They explore the boundary where deterministic application-owned meaning meets probabilistic language models.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ProtocolAI&lt;/strong&gt; is the &lt;strong&gt;WHAT&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It gives application-owned meaning a deterministic semantic address space.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GrammarAI&lt;/strong&gt; is the &lt;strong&gt;HOW&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It gives those identities a structural language for describing how they can be connected.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;LLM
 |
 | probability
 v
ProtocolAI
   WHAT
 |
 v
GrammarAI
   HOW
 |
 v
host / adapter
 |
 v
application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is deliberately no LLM hidden inside either package.&lt;/p&gt;
&lt;p&gt;No provider SDK.&lt;/p&gt;
&lt;p&gt;No inference engine.&lt;/p&gt;
&lt;p&gt;No chatbot.&lt;/p&gt;
&lt;p&gt;No API-key management.&lt;/p&gt;
&lt;p&gt;The application owns the meaning.&lt;/p&gt;
&lt;p&gt;The model supplies probability.&lt;/p&gt;
&lt;p&gt;The deterministic boundary sits in between.&lt;/p&gt;
&lt;p&gt;I wrote more about the idea here:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/6795/protocolai-and-grammarai-giving-ai-a-language-software-can-actually-own&quot;&gt;ProtocolAI and GrammarAI: Giving AI a Language Software Can Actually Own&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-hosts&quot;&gt;The Hosts&lt;/h2&gt;
&lt;p&gt;Architecture diagrams are great.&lt;/p&gt;
&lt;p&gt;Eventually, though, somebody has to push a button.&lt;/p&gt;
&lt;h3 id=&quot;anyapp&quot;&gt;AnyApp&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/AnyApp&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AnyApp is the &lt;strong&gt;desktop proof of the composition architecture&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It is the Windows desktop counterpart to the browser-facing WebPage/WebForge host.&lt;/p&gt;
&lt;p&gt;The important part is that AnyApp is trying very hard to remain a &lt;strong&gt;host&lt;/strong&gt;, rather than becoming an application-specific framework.&lt;/p&gt;
&lt;p&gt;Startup is now manifest-driven:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;AnyApp
  |
  v
startup / last Experience
  |
  v
Forge fallback when necessary
  |
  v
FSM_COS
  |
  v
RuntimeAssembly
  |
  v
host manifestation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The host doesn't need to know every Experience it might eventually run.&lt;/p&gt;
&lt;p&gt;It hosts the composition.&lt;/p&gt;
&lt;p&gt;That's the experiment.&lt;/p&gt;
&lt;p&gt;And yes, the current window is tiny.&lt;/p&gt;
&lt;p&gt;That's part of the fun.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;webpage&quot;&gt;WebPage&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/WebPage&quot;&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the &lt;strong&gt;public proving ground&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;WebPage is the ephemeral web manifestation of the larger Workshop: public-facing surface, demonstration environment, and experimental place where the architecture gets to meet an actual visitor.&lt;/p&gt;
&lt;p&gt;The guiding idea is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Make the mundane magical. Then show the machinery that made it possible.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Instead of merely putting diagrams on a webpage explaining that the GUI is dynamic, the page itself should eventually behave dynamically.&lt;/p&gt;
&lt;p&gt;A button can breathe.&lt;/p&gt;
&lt;p&gt;A GUI can reproduce.&lt;/p&gt;
&lt;p&gt;The reproduction can become a swarm.&lt;/p&gt;
&lt;p&gt;The swarm can overwhelm the screen.&lt;/p&gt;
&lt;p&gt;The system can freeze.&lt;/p&gt;
&lt;p&gt;Gravity can take over.&lt;/p&gt;
&lt;p&gt;And suddenly the visitor isn't just reading about the architecture anymore.&lt;/p&gt;
&lt;p&gt;They're watching the architecture become the experience.&lt;/p&gt;
&lt;p&gt;That's what the WebPage repository is for.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;you-don-t-need-to-understand-all-of-this&quot;&gt;You Don't Need to Understand All of This&lt;/h2&gt;
&lt;p&gt;Seriously.&lt;/p&gt;
&lt;p&gt;You don't need to read every README.&lt;/p&gt;
&lt;p&gt;You don't need to understand FSM_COS before touching FSM_API.&lt;/p&gt;
&lt;p&gt;You don't need to understand MicroBundle arbitration before playing with the GUI.&lt;/p&gt;
&lt;p&gt;You certainly don't need to agree with my architecture.&lt;/p&gt;
&lt;p&gt;You don't need to know what a “semantic address space” is.&lt;/p&gt;
&lt;p&gt;You don't need to know what “arbitration” means.&lt;/p&gt;
&lt;p&gt;You don't even need to know why I keep naming things with increasingly suspicious acronyms.&lt;/p&gt;
&lt;p&gt;Just pick one piece that looks interesting.&lt;/p&gt;
&lt;p&gt;Install it.&lt;/p&gt;
&lt;p&gt;Read the source.&lt;/p&gt;
&lt;p&gt;Build a tiny experiment.&lt;/p&gt;
&lt;p&gt;Benchmark it.&lt;/p&gt;
&lt;p&gt;Break it.&lt;/p&gt;
&lt;p&gt;Tell me why it's broken.&lt;/p&gt;
&lt;p&gt;That is the entire point of publishing early.&lt;/p&gt;
&lt;p&gt;Software has to meet reality.&lt;/p&gt;
&lt;p&gt;And reality includes other developers.&lt;/p&gt;
&lt;p&gt;Some APIs will change.&lt;/p&gt;
&lt;p&gt;Some experiments will become load-bearing infrastructure.&lt;/p&gt;
&lt;p&gt;Some ideas that seem perfectly reasonable right now will probably turn out to be terrible.&lt;/p&gt;
&lt;p&gt;Some will get thrown away.&lt;/p&gt;
&lt;p&gt;That's normal.&lt;/p&gt;
&lt;p&gt;Actually, that's part of the process.&lt;/p&gt;
&lt;p&gt;The important thing is that these aren't only diagrams anymore.&lt;/p&gt;
&lt;p&gt;They're code.&lt;/p&gt;
&lt;p&gt;They're packages.&lt;/p&gt;
&lt;p&gt;They're repositories.&lt;/p&gt;
&lt;p&gt;They're things you can actually touch.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-workshop-is-open&quot;&gt;The Workshop Is Open&lt;/h2&gt;
&lt;p&gt;So, that's the current toy box.&lt;/p&gt;
&lt;p&gt;Not everything is finished.&lt;/p&gt;
&lt;p&gt;Not everything is stable.&lt;/p&gt;
&lt;p&gt;Not everything is even guaranteed to survive.&lt;/p&gt;
&lt;p&gt;That's why I'm putting it out now.&lt;/p&gt;
&lt;p&gt;If something catches your eye, &lt;strong&gt;go play with it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;And if you find something interesting, confusing, inefficient, backwards, brilliant, terrible, or just plain weird—&lt;/p&gt;
&lt;p&gt;tell me.&lt;/p&gt;
&lt;p&gt;That's how experiments turn into software.&lt;/p&gt;
&lt;p&gt;And how software turns into infrastructure.&lt;/p&gt;
&lt;p&gt;The toy box is open.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It's the weekend. Come play.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Support Our Work:&lt;/p&gt;
&lt;p&gt;Patreon Page:&lt;br&gt;
[https://www.patreon.com/c/TheSingularityWorkshop][1]&lt;/p&gt;
&lt;p&gt; Support Us (PayPal Donation):&lt;br&gt;
[https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J][2]&lt;/p&gt;
&lt;p&gt;We'd love to hear your thoughts! Please Like this post, Love the code, and Share your feedback in the comments.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15913825524075916601&quot; alt=&quot;A Hyper-Detailed, Architecturally Impossible Synthesis of Consciousness and Digital Matter. The image is a frenetic, deeply complex digital vista where a central Luminous Eye dominates a vast, glowing circuit landscape. This eye, suspended mid-air, is a sphere of intense, fractal energy—a pulsating vortex of pink, violet, and electric blue light—that powerfully suggests an emergence of digital consciousness or a Technological Singularity itself. The core is a bottomless black aperture, ringed by a white-hot plasma disc. Below this ocular energy source, the light dissipates into an intricate, copper-gold and neon-blue Circuit Board Megastructure that stretches to the horizon, impossibly dense with exaggerated microchips, glowing resistor arrays, and power conduits that form deep, glowing canyons. The background is a dark, holographic projection field displaying complex schematics, mathematical models, and flowing data streams, reinforcing the theme of Absolute Digital Engineering. The entire scene is bathed in a dramatic, opposing light source—a warm, orange-gold glow from the left and an ice-cold, electric-blue glow from the right—creating a maximalist, high-contrast visual experience that definitively rejects the minimalist simplicity of conventional design and announces: Technology is intricate, overwhelming, and infinitely complex.&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29600/its-the-weekend-the-workshop-has-toys-come-play</guid>
<pubDate>Sat, 03 Oct 2026 07:14:18 +0000</pubDate>
</item>
<item>
<title>A Row Version Is a Cross-Layer Contract</title>
<link>https://coderlegion.com/29596/a-row-version-is-a-cross-layer-contract</link>
<description>&lt;p&gt;A concurrency column can exist in the database and still be wrong.&lt;/p&gt;
&lt;p&gt;The name may match. The migration may have run. Inserts may appear healthy. Yet optimistic concurrency can still be weaker than the application assumes because the runtime model, the physical schema, and the test environment disagree about what that column means.&lt;/p&gt;
&lt;p&gt;The lesson is broader than one ORM feature: database-generated values are cross-layer contracts. Treating them as a property of only the entity class or only the table leaves a gap exactly where correctness matters.&lt;/p&gt;
&lt;h3 id=&quot;what-the-contract-actually-contains&quot;&gt;What the contract actually contains&lt;/h3&gt;
&lt;p&gt;For a database-generated row version, at least five facts need to agree:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the physical type has true row-version semantics;&lt;/li&gt;
&lt;li&gt;the value is non-nullable;&lt;/li&gt;
&lt;li&gt;the database generates it on insert and update;&lt;/li&gt;
&lt;li&gt;the ORM marks it as a concurrency token;&lt;/li&gt;
&lt;li&gt;existing values remain intact during alignment or deployment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A binary column of the expected length is not automatically equivalent. A nullable token changes the invariant. A model that knows about concurrency but not database generation can try to write a value the database owns. Conversely, a correctly generated database column provides little application protection if the runtime model never includes it in optimistic-concurrency checks.&lt;/p&gt;
&lt;p&gt;The useful mental model is not “there is a column.” It is “every layer agrees who owns this value and how a stale write is detected.”&lt;/p&gt;
&lt;h3 id=&quot;prefer-verification-over-destructive-repair&quot;&gt;Prefer verification over destructive repair&lt;/h3&gt;
&lt;p&gt;Suppose the shipped database already has the correct non-null generated token, but the current EF Core model describes it incompletely. A tempting fix is to alter or recreate the column until the migration history looks tidy.&lt;/p&gt;
&lt;p&gt;That can be the riskiest part of the change. Replacing a generated token can touch every row, invalidate values held by active writers, or disguise an unexpected schema. The safer move is often smaller:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;align the runtime mapping with the physical contract;&lt;/li&gt;
&lt;li&gt;add a forward-only migration that asserts the exact expected shape;&lt;/li&gt;
&lt;li&gt;refuse to proceed when the real schema differs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This kind of assertion migration changes no business data. It asks the database whether the table, column, type, length, nullability, and generation semantics match the known contract. Running it twice should be safe. Encountering a missing, nullable, or look-alike column should fail clearly.&lt;/p&gt;
&lt;p&gt;Failing a rollout is inconvenient. Quietly weakening stale-write protection is worse. A fail-closed migration turns unknown drift into an explicit operational decision instead of an accidental data-integrity change.&lt;/p&gt;
&lt;h3 id=&quot;test-at-three-different-levels&quot;&gt;Test at three different levels&lt;/h3&gt;
&lt;p&gt;No single test provider can prove the whole contract efficiently. A layered suite gives each test one honest job.&lt;/p&gt;
&lt;h4 id=&quot;1-model-metadata&quot;&gt;1. Model metadata&lt;/h4&gt;
&lt;p&gt;A cheap unit test can inspect EF Core metadata. It should verify that the property is required, marked as a concurrency token, and generated on add or update. This catches accidental mapping changes quickly, without starting a database.&lt;/p&gt;
&lt;p&gt;Metadata tests do not prove the database behaves that way. They prove the application describes its expectation correctly.&lt;/p&gt;
&lt;h4 id=&quot;2-lightweight-application-flows&quot;&gt;2. Lightweight application flows&lt;/h4&gt;
&lt;p&gt;In-memory providers are useful for service and orchestration tests, but they do not automatically behave like a relational engine with generated row versions. Once a token becomes required, fixtures may fail because no value is produced.&lt;/p&gt;
&lt;p&gt;A narrowly scoped, test-only save interceptor can generate a fresh fixed-length token for inserted or modified entities. That keeps fast tests representative of the required-value lifecycle. It should be described honestly as an emulator, not provider parity.&lt;/p&gt;
&lt;p&gt;The emulator proves that application code can carry changing tokens through its flows. It cannot prove the SQL type, migration assertion, or actual stale-write mechanics.&lt;/p&gt;
&lt;h4 id=&quot;3-a-focused-real-database-proof&quot;&gt;3. A focused real-database proof&lt;/h4&gt;
&lt;p&gt;The decisive test uses the real provider. Create the shipped shape, insert a synthetic row, and retain its generated token. Apply the alignment migration—ideally twice—and verify that both the row and original token survive.&lt;/p&gt;
&lt;p&gt;Then load the same row through two contexts. Save through the first and confirm its token changes. Attempt to save the stale copy through the second. The expected concurrency exception is the behavioural proof that the contract works end to end.&lt;/p&gt;
&lt;p&gt;Negative cases matter too. A missing table, missing column, nullable token, or ordinary binary column should be refused. These cases prevent a permissive assertion from becoming ceremonial.&lt;/p&gt;
&lt;h3 id=&quot;put-the-expensive-proof-on-the-required-path&quot;&gt;Put the expensive proof on the required path&lt;/h3&gt;
&lt;p&gt;A real-database test that developers can skip forever is documentation, not a gate. Register the small provider-faithful suite in the required CI job and include the relevant file scopes in change detection.&lt;/p&gt;
&lt;p&gt;This does not mean every application test should use a full database. That would make feedback slower and fixtures harder to maintain. The trade-off is deliberate separation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;metadata tests are cheap and broad;&lt;/li&gt;
&lt;li&gt;in-memory tests exercise application flows;&lt;/li&gt;
&lt;li&gt;real-database tests prove database-owned behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The suite costs more than one happy-path unit test. In return, failures become easier to interpret because each layer states what it can and cannot guarantee.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-5&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a database-generated concurrency token, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the runtime model match the shipped type, nullability, and generation rules?&lt;/li&gt;
&lt;li&gt;Does the migration preserve rows and existing token values?&lt;/li&gt;
&lt;li&gt;Does unexpected schema fail closed?&lt;/li&gt;
&lt;li&gt;Do fast fixtures represent the required token lifecycle without claiming provider equivalence?&lt;/li&gt;
&lt;li&gt;Does a real-provider test prove token change and stale-write rejection?&lt;/li&gt;
&lt;li&gt;Is that proof part of required CI?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The broader engineering habit is simple: when a value is owned by the database, verify the contract where each promise is made. Model tests describe intent. Fast tests protect application flow. Only the real provider can prove the final integrity boundary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29596/a-row-version-is-a-cross-layer-contract</guid>
<pubDate>Sat, 03 Oct 2026 06:24:18 +0000</pubDate>
</item>
<item>
<title>Project the Response, Not the Cache</title>
<link>https://coderlegion.com/29355/project-the-response-not-the-cache</link>
<description>&lt;p&gt;A response can be correct for one caller and still leave the application in a worse state for everyone who follows.&lt;/p&gt;
&lt;p&gt;This is an easy trap in ASP.NET Core when a result filter, formatter, or middleware needs to change a few fields before a response leaves the server. Localization is a useful example: a DTO contains source-language text, the current request asks for another language, and the pipeline replaces selected strings before serialization.&lt;/p&gt;
&lt;p&gt;The first response looks right. The implementation may even be pleasantly small.&lt;/p&gt;
&lt;p&gt;The danger is ownership. If the returned DTO came from an in-process cache, the response pipeline may be holding the same object instance that another request, background job, or command path will read later. What looked like response formatting was actually a write to shared state.&lt;/p&gt;
&lt;p&gt;The broader lesson is this: request-specific presentation should be a projection, not a mutation.&lt;/p&gt;
&lt;h3 id=&quot;the-bug-hides-behind-a-successful-response&quot;&gt;The Bug Hides Behind a Successful Response&lt;/h3&gt;
&lt;p&gt;Imagine a cached catalogue item with a description in the system’s source language. A request asks for a translated version. The tempting implementation walks the object graph and assigns the translated string to the DTO property.&lt;/p&gt;
&lt;p&gt;The response is translated correctly. A test that checks only that response passes.&lt;/p&gt;
&lt;p&gt;Now consider the next reader. If the cache returns the same instance, the cached item is no longer in its authoritative source form. A source-language request may receive translated text. A command that maps the DTO back into another model may persist presentation text as source data. A background process may observe a value chosen for somebody else’s request.&lt;/p&gt;
&lt;p&gt;Nothing about the first HTTP response reveals this. The defect is temporal: the observable failure belongs to a later reader.&lt;/p&gt;
&lt;p&gt;That is why “the output is correct” is not a sufficient assertion when inputs can be shared.&lt;/p&gt;
&lt;h3 id=&quot;draw-the-ownership-boundary-explicitly&quot;&gt;Draw the Ownership Boundary Explicitly&lt;/h3&gt;
&lt;p&gt;A useful model is to separate three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The cached or domain DTO is authoritative input.&lt;/li&gt;
&lt;li&gt;Request context selects presentation rules.&lt;/li&gt;
&lt;li&gt;The HTTP payload is response-owned output.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once those roles are explicit, several safe implementations become available. You can map into a dedicated response model. You can clone the graph and change the clone. Or you can project replacement values at the serialization boundary.&lt;/p&gt;
&lt;p&gt;In the last approach, the traversal discovers which string properties need different values but never assigns those values to the source objects. The serializer then substitutes the replacements while creating a JSON tree owned by the response. Nested objects, collections, readonly wrappers, and init-only properties keep their wire shape because the original serializer contract still defines the payload.&lt;/p&gt;
&lt;p&gt;The important property is not the particular API used. It is that no request-specific operation writes into caller-owned or cache-owned objects.&lt;/p&gt;
&lt;p&gt;This idea also applies to redaction, unit formatting, enrichment, API-version shaping, and field masking. If the transformation varies by caller or request, it probably belongs in an output projection.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-common-case-fast-path&quot;&gt;Preserve the Common-Case Fast Path&lt;/h3&gt;
&lt;p&gt;Projection has a cost. It may require an additional JSON materialization, replacement lookup, and short-lived allocations. That cost should be acknowledged rather than hidden behind a correctness slogan.&lt;/p&gt;
&lt;p&gt;The practical response is to make the cheap path obvious. When the request already uses the source representation, pass the original result through untouched. Only requests that need transformation pay for projection.&lt;/p&gt;
&lt;p&gt;Failure behavior also matters. If the projection cannot handle an unusual graph, the pipeline should not leave a half-mutated object behind. A safe fallback retains the original result and logs the failure according to the application’s policy. Whether returning the untransformed payload is acceptable depends on the feature and data classification; sensitive redaction may need to fail closed instead.&lt;/p&gt;
&lt;p&gt;Security-sensitive payloads and error results may also deserve explicit exclusion. Presentation machinery should not accidentally inspect tokens, secrets, or payload shapes that were never part of its contract.&lt;/p&gt;
&lt;h3 id=&quot;test-the-second-reader&quot;&gt;Test the Second Reader&lt;/h3&gt;
&lt;p&gt;The highest-value test is a sequence, not a snapshot:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Put a source-language object into the real cache implementation used by the host.&lt;/li&gt;
&lt;li&gt;Request a transformed response and verify its output.&lt;/li&gt;
&lt;li&gt;Read the cached object again and verify that it is unchanged.&lt;/li&gt;
&lt;li&gt;Issue a source-language request and verify that it still receives the source value.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Run that sequence through the real result filter and serializer options, not only a mocked translation service. The ownership bug lives in the composition between cache identity, result handling, graph traversal, and serialization.&lt;/p&gt;
&lt;p&gt;Add focused cases for ordinary object results and explicit JSON results, nested collections, readonly response wrappers, init-only properties, error responses, exempt payloads, and projection failure. These tests establish both the intended transformation and the things it must never change.&lt;/p&gt;
&lt;p&gt;A mutation-resistance assertion is especially valuable: keep a reference to the original object and prove its properties retain their exact values after the response executes.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-isolation-versus-local-simplicity&quot;&gt;The Trade-Off Is Isolation Versus Local Simplicity&lt;/h3&gt;
&lt;p&gt;In-place mutation is easy to understand in one method and can avoid an extra materialization. Its cost appears elsewhere: temporal coupling, language or user context leaking between requests, and tests that pass individually while failing in sequence.&lt;/p&gt;
&lt;p&gt;Response projection adds code, allocations, and serializer-specific reasoning. In return, it makes ownership visible. The cache remains authoritative, requests remain isolated, and failures cannot leave shared objects partially transformed.&lt;/p&gt;
&lt;p&gt;That is a trade I will usually take. I would still measure the transformed path with representative payload sizes and keep the source path cheap. But performance work should begin after the ownership contract is correct.&lt;/p&gt;
&lt;p&gt;The practical rule is short: if a value is shared, treat it as immutable. If presentation depends on the request, produce response-owned output. And when you test it, always ask what the next reader sees.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29355/project-the-response-not-the-cache</guid>
<pubDate>Thu, 01 Oct 2026 05:50:20 +0000</pubDate>
</item>
<item>
<title>Treat Every ID as an Authorization Claim</title>
<link>https://coderlegion.com/29225/treat-every-id-as-an-authorization-claim</link>
<description>&lt;p&gt;Authentication answers one important question: who made this request? It does not answer a second question that often hides inside an ordinary-looking query parameter: may this caller name the record represented by this identifier?&lt;/p&gt;
&lt;p&gt;That gap is easy to miss in APIs that accept a parent ID, child ID, account ID, project ID, or tenant ID. The route is authenticated. The identifier has the correct shape. The query returns a valid result. Yet none of those facts proves that the record belongs inside the caller's authorized scope.&lt;/p&gt;
&lt;p&gt;A useful engineering rule is to treat every caller-supplied identifier as an authorization claim before treating it as a query input.&lt;/p&gt;
&lt;h3 id=&quot;authentication-is-not-object-ownership&quot;&gt;Authentication Is Not Object Ownership&lt;/h3&gt;
&lt;p&gt;Imagine an endpoint that returns activities for a supplied household record. The controller requires a signed-in user, then passes the supplied ID into a query service. If the ID is changed in transit, the query may still succeed—just for somebody else's data.&lt;/p&gt;
&lt;p&gt;The vulnerable assumption is subtle: “signed in” becomes “allowed to ask about any existing record.” The database faithfully answers the question it receives. The missing control belongs before the query.&lt;/p&gt;
&lt;p&gt;The server should derive the caller's authority from trusted identity and relationship data. For an ordinary user, that might mean resolving the account to its authoritative person record, then traversing explicit relationships to the permitted records. For an operational role, a specific permission may allow a wider scope because that role already performs work requiring the same visibility.&lt;/p&gt;
&lt;p&gt;This also explains why email equality is a poor ownership rule. Addresses are corrected, reused, shared by families, copied into contact fields, and sometimes retained after account changes. An email can help locate a candidate during migration, but it should not silently replace an authoritative account-to-person relationship.&lt;/p&gt;
&lt;h3 id=&quot;put-the-boundary-before-the-query&quot;&gt;Put the Boundary Before the Query&lt;/h3&gt;
&lt;p&gt;A safe request pipeline makes the order explicit:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Identify the caller from trusted authentication state.&lt;/li&gt;
&lt;li&gt;Resolve the caller's authoritative records and relationships on the server.&lt;/li&gt;
&lt;li&gt;Evaluate each supplied identifier against that scope.&lt;/li&gt;
&lt;li&gt;Only then construct and execute the data query.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This order prevents a common failure mode: retrieving a broad result and hoping a later projection removes everything sensitive. Late filtering spreads the security contract across repositories, mappers, and UI code. Early scope resolution gives the query a smaller, already-authorized input.&lt;/p&gt;
&lt;p&gt;Do not bundle independent identifiers into one vague decision. If a request contains both a parent filter and a child filter, check each one. Mixed requests are exactly where “one valid field makes the request valid” bugs appear. A valid parent must not bless an unrelated child; an unrelated parent must not erase a valid child's boundary.&lt;/p&gt;
&lt;h3 id=&quot;reject-by-default-narrow-only-deliberately&quot;&gt;Reject by Default, Narrow Only Deliberately&lt;/h3&gt;
&lt;p&gt;For most data-specific routes, a cross-scope identifier should be rejected. Rejection is direct, observable, and easier to reason about. It tells the client that its request crossed a boundary and gives operators a signal worth investigating.&lt;/p&gt;
&lt;p&gt;Legacy mobile clients complicate that clean answer. An installed client may send an old identifier shape, interpret a forbidden response as a session failure, or sign the user out. Waiting for every old version to disappear can leave a real data boundary open longer than necessary.&lt;/p&gt;
&lt;p&gt;There is a narrow alternative: compatibility-preserving narrowing. The server validates every supplied identifier, discards any that are not authorized, and executes only a known-safe baseline query. The key invariant is monotonicity: removing an unauthorized filter must never widen the response into the requested protected data.&lt;/p&gt;
&lt;p&gt;For example, a calendar route might combine household-specific entries with public entries. If an old client sends an account ID where a domain record ID is expected, the server can remove that invalid household filter and return only the public baseline. It must not substitute another household, guess from a weak attribute, or run an unscoped “all entries” query.&lt;/p&gt;
&lt;p&gt;This is not a general recommendation to hide authorization failures. It is an endpoint-specific migration tool. Document why it exists, record how it is used, and give it a removal condition.&lt;/p&gt;
&lt;h3 id=&quot;test-the-boundary-not-just-the-happy-path&quot;&gt;Test the Boundary, Not Just the Happy Path&lt;/h3&gt;
&lt;p&gt;Authorization tests should form a matrix around relationships and permissions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the caller names their own authoritative record;&lt;/li&gt;
&lt;li&gt;the caller names a related child record;&lt;/li&gt;
&lt;li&gt;the caller names another household's record;&lt;/li&gt;
&lt;li&gt;the caller has no corresponding relationship;&lt;/li&gt;
&lt;li&gt;a shared or copied email appears on a record the caller does not own;&lt;/li&gt;
&lt;li&gt;a staff role lacks the exact cross-scope permission;&lt;/li&gt;
&lt;li&gt;a staff role holds that permission;&lt;/li&gt;
&lt;li&gt;one filter is authorized while another is not;&lt;/li&gt;
&lt;li&gt;the legacy request shape still receives the same safe baseline.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last two cases are especially valuable. Mixed filters catch accidental “any match” logic. Legacy-shape tests prevent a future cleanup from turning a deliberate compatibility response back into either a leak or a destructive client error.&lt;/p&gt;
&lt;p&gt;Tests should assert returned data as well as status codes. A successful response is safe only if its contents stay inside the proven scope. A forbidden response is useful only if the client contract can tolerate it.&lt;/p&gt;
&lt;h3 id=&quot;make-the-exception-expensive-to-forget&quot;&gt;Make the Exception Expensive to Forget&lt;/h3&gt;
&lt;p&gt;Compatibility code tends to become permanent when its rationale lives only in memory. Keep the exception close to the boundary, name the safe baseline, add telemetry that contains no sensitive identifiers, and define the client version or date after which strict rejection can replace narrowing.&lt;/p&gt;
&lt;p&gt;The trade-off is real. Strict rejection gives a simpler model and clearer misuse signals. Narrowing adds policy, tests, and the possibility of masking a broken client. Its value is speed: the server can close the data boundary now while preserving a safe experience for older installations.&lt;/p&gt;
&lt;p&gt;The practical standard is modest but powerful: never let a caller-provided ID reach a query merely because it parses. First prove that the caller may name it. If compatibility forces an exception, make the request narrower, make the safe response explicit, and make the exception temporary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29225/treat-every-id-as-an-authorization-claim</guid>
<pubDate>Wed, 30 Sep 2026 10:38:55 +0000</pubDate>
</item>
<item>
<title>A Lifetime Is a Hold, Not the Worker</title>
<link>https://coderlegion.com/29003/a-lifetime-is-a-hold-not-the-worker</link>
<description>&lt;p&gt;&lt;code&gt;IDisposable&lt;/code&gt; and &lt;code&gt;IAsyncDisposable&lt;/code&gt; make lifetime management feel pleasantly local: acquire something, use it, dispose it. That model is reliable when the acquired value has exclusive ownership of an independent resource.&lt;/p&gt;
&lt;p&gt;It becomes dangerous when several activations share one background worker.&lt;/p&gt;
&lt;p&gt;Mobile applications encounter this more often than it first appears. Returning online can reconcile a capability that is already running. A foreground transition can overlap an earlier activation. A bounded startup wait can stop waiting for slow native work, while that work completes after a newer activation has already joined the same resource.&lt;/p&gt;
&lt;p&gt;If every returned lifetime means “stop the worker,” one participant can tear down work that another still needs. The sharper contract is: a returned lifetime is a hold on shared work. Disposing it releases only that participant’s hold. The worker stops when the final hold leaves.&lt;/p&gt;
&lt;h3 id=&quot;the-bug-hides-behind-a-reasonable-api&quot;&gt;The bug hides behind a reasonable API&lt;/h3&gt;
&lt;p&gt;Imagine a mobile shell with two optional capabilities: a small status refresher and a device-token relay. Each activation returns an asynchronous lifetime to the orchestrator. The orchestrator keeps the latest lifetime and disposes the one it replaces.&lt;/p&gt;
&lt;p&gt;That sounds tidy, but activations are not necessarily independent. The second activation may join the refresher or event relay started by the first. If the first lifetime calls a global &lt;code&gt;StopAsync&lt;/code&gt;, disposing the replaced lifetime stops the shared worker beneath the second activation.&lt;/p&gt;
&lt;p&gt;The state machine can now tell a convincing lie. Its newest activation completed successfully, so the capability still appears ready. Underneath, polling has stopped or the event handler has been removed.&lt;/p&gt;
&lt;p&gt;Warning, cancellation, and stale-completion paths deserve the same scrutiny. Cleanup code is often written defensively in several branches. If each branch performs terminal teardown, one failed attempt can shut down a resource held by a successful attempt.&lt;/p&gt;
&lt;h3 id=&quot;write-the-ownership-sentence-first&quot;&gt;Write the ownership sentence first&lt;/h3&gt;
&lt;p&gt;Before implementing the lifetime, describe what disposal means:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Disposing this value releases this activation’s participation. It does not necessarily stop the shared resource.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sentence changes the design. Starting becomes “start or join, then take a hold.” Releasing becomes “release this hold, then stop only if it was the last.” Both transitions need one synchronization boundary.&lt;/p&gt;
&lt;p&gt;It is not enough to increment a counter and subscribe in separate steps. Another thread can observe the gap. A release can unsubscribe after a new hold has been counted, or two starts can install duplicate listeners. The zero-to-one decision and the actual start belong in one atomic operation. So do the one-to-zero decision and the stop.&lt;/p&gt;
&lt;p&gt;The hold itself should release at most once. A double &lt;code&gt;Dispose&lt;/code&gt; must not decrement the count twice. A failed start must not record a hold, otherwise the next activation may “join” work that never started.&lt;/p&gt;
&lt;h3 id=&quot;old-holds-must-not-reach-into-new-runs&quot;&gt;Old holds must not reach into new runs&lt;/h3&gt;
&lt;p&gt;Reference counting alone is not sufficient when the resource can stop and restart.&lt;/p&gt;
&lt;p&gt;Suppose an old activation holds generation A. The application moves offline, so generation A is stopped. Later, the application returns online and starts generation B. If the late lifetime from A merely decrements a global count, it can steal a hold from B and stop the restarted worker.&lt;/p&gt;
&lt;p&gt;Bind each hold to the run it joined. Releasing a hold from an ended run becomes a no-op for the current one.&lt;/p&gt;
&lt;p&gt;The orchestrator may need a run identity too. Some stop events invalidate a broad session lease; others, such as backgrounding or going offline, may intentionally leave that session identity unchanged. A slow activation can therefore finish with a lease that still looks current even though the capability run it belonged to has ended. Tracking the capability run separately prevents that late completion from being adopted as ready.&lt;/p&gt;
&lt;p&gt;This distinction is useful beyond mobile software. Connection pools, shared subscriptions, refresh loops, file watchers, and in-process event relays all have “same logical session, different resource run” failure modes.&lt;/p&gt;
&lt;h3 id=&quot;test-transitions-not-only-outcomes&quot;&gt;Test transitions, not only outcomes&lt;/h3&gt;
&lt;p&gt;A happy-path test that starts and stops once proves very little about shared ownership.&lt;/p&gt;
&lt;p&gt;Useful tests make each boundary visible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;take two holds, release one, and prove the worker remains active;&lt;/li&gt;
&lt;li&gt;release the final hold and prove the worker stops exactly once;&lt;/li&gt;
&lt;li&gt;dispose one hold twice and prove it releases once;&lt;/li&gt;
&lt;li&gt;fail the first start and prove a later acquisition tries again;&lt;/li&gt;
&lt;li&gt;restart the resource and prove an old hold cannot affect the new run;&lt;/li&gt;
&lt;li&gt;complete an abandoned activation after a newer one and prove the newer work survives; and&lt;/li&gt;
&lt;li&gt;repeatedly take, inspect, and release holds from two threads while the count crosses zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last test is important. A single-thread loop can leave mutation survivors because it never exposes the gap between “count says active” and “listener is actually active.” Two competing threads make zero crossings collide with acquisition and release, which is where split synchronization shows itself.&lt;/p&gt;
&lt;p&gt;Concurrency tests still need discipline: bounded rounds, a clear invariant, and no claim that passing them mathematically proves correctness. Their job is to exercise the race window while deterministic tests pin the state-machine rules.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-trade-off&quot;&gt;The honest trade-off&lt;/h3&gt;
&lt;p&gt;Counted holds add a lock, a run identifier, idempotent release state, and more involved tests. They also force a clear distinction between releasing one participant and terminally disposing the owning dependency scope.&lt;/p&gt;
&lt;p&gt;The simpler design is to prohibit overlap. That can be the right choice when the orchestrator can guarantee one activation at a time, cancel it reliably, and wait for completion before starting another. But if native calls can outlive a budget, or reconciliation can join already-running work, the simplicity is imaginary.&lt;/p&gt;
&lt;p&gt;The practical takeaway is broader than reference counting: make ownership semantics part of the public contract. When a lifetime represents participation in shared work, call it a hold and test it like one. A local cleanup action should never silently become a global stop.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29003/a-lifetime-is-a-hold-not-the-worker</guid>
<pubDate>Tue, 29 Sep 2026 05:58:20 +0000</pubDate>
</item>
<item>
<title>I Built a Serializer That Refuses to Own Your Data</title>
<link>https://coderlegion.com/28979/i-built-a-serializer-that-refuses-to-own-your-data</link>
<description>&lt;h2 id=&quot;i-built-a-serializer-that-refuses-to-own-your-data&quot;&gt;I Built a Serializer That Refuses to Own Your Data&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How The Singularity Workshop turned a recurring binary-IO problem into a tiny .NET boundary—and why the smallest part of the design may be the most important part.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is a particular kind of software problem that appears harmless at first.&lt;/p&gt;
&lt;p&gt;You have some state.&lt;/p&gt;
&lt;p&gt;You need to turn it into bytes.&lt;/p&gt;
&lt;p&gt;So you write a serializer.&lt;/p&gt;
&lt;p&gt;Then another part of the application needs bytes, so you add another serializer.&lt;/p&gt;
&lt;p&gt;Then a cache needs them.&lt;/p&gt;
&lt;p&gt;Then a manifest needs them.&lt;/p&gt;
&lt;p&gt;Then a transport layer needs them.&lt;/p&gt;
&lt;p&gt;Then somebody decides JSON is better here, binary is better there, and suddenly the architecture has six different opinions about what a piece of data &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That was the problem I was running into while building The Singularity Workshop.&lt;/p&gt;
&lt;p&gt;The interesting part is that I didn't actually need a giant serialization framework.&lt;/p&gt;
&lt;p&gt;I needed a &lt;strong&gt;boundary&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;So I built one.&lt;/p&gt;
&lt;p&gt;It is called:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TheSingularityWorkshop.FSM_Serialization&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And it is deliberately small.&lt;/p&gt;
&lt;p&gt;That is the point.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem-wasn-t-turning-objects-into-bytes&quot;&gt;The Problem Wasn't Turning Objects Into Bytes&lt;/h3&gt;
&lt;p&gt;Serialization is usually described as a mechanical operation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;object → serializer → bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That description hides something important.&lt;/p&gt;
&lt;p&gt;An object has meaning.&lt;/p&gt;
&lt;p&gt;Bytes do not.&lt;/p&gt;
&lt;p&gt;Bytes are a representation of meaning.&lt;/p&gt;
&lt;p&gt;That distinction becomes increasingly important as an architecture grows.&lt;/p&gt;
&lt;p&gt;The Workshop ecosystem is being built around several deliberately separated responsibilities:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FSM_API
    │
    │ behavior and state
    ▼
FSM_COS
    │
    │ composition and runtime assembly
    ▼
FSM_Serialization
    │
    │ representation
    ▼
storage / transport / host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I didn't want FSM_COS to become a serializer.&lt;/p&gt;
&lt;p&gt;I didn't want a MicroBundle to have to know how a filesystem works.&lt;/p&gt;
&lt;p&gt;I didn't want a database abstraction hiding inside a representation layer.&lt;/p&gt;
&lt;p&gt;And I definitely didn't want every higher-level system inventing its own binary stream interface.&lt;/p&gt;
&lt;p&gt;So I extracted the smallest reusable boundary I could identify.&lt;/p&gt;
&lt;p&gt;That boundary became FSM_Serialization.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-first-design-decision&quot;&gt;The First Design Decision&lt;/h2&gt;
&lt;p&gt;The most important rule in the package is also the simplest:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The type that owns the meaning should own the representation rules.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=16908214559991722359&quot; alt=&quot;Ai rendering of the serialization as a process&quot;&gt;&lt;/p&gt;
&lt;p&gt;FSM_Serialization provides the machinery for crossing the byte boundary.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; decide what your data means.&lt;/p&gt;
&lt;p&gt;That means the package doesn't contain some enormous reflection-based object graph engine that looks at your class and decides what should be serialized.&lt;/p&gt;
&lt;p&gt;Instead, the type explicitly says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryPackable
{
    void Pack(IBinaryStream stream);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the reverse direction is equally explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryUnpackable
{
    void Unpack(IBinaryStream stream);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a type needs both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinarySerializable :
    IBinaryPackable,
    IBinaryUnpackable
{
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the entire conceptual model.&lt;/p&gt;
&lt;p&gt;No magic.&lt;/p&gt;
&lt;p&gt;No global serializer registry.&lt;/p&gt;
&lt;p&gt;No reflection-based guesswork.&lt;/p&gt;
&lt;p&gt;No framework deciding that a private field suddenly became part of your persistent format.&lt;/p&gt;
&lt;p&gt;The domain owns that decision.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-i-didn-t-just-use-system-io-stream&quot;&gt;Why I Didn't Just Use &lt;code&gt;System.IO.Stream&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This is probably the first question a .NET developer will ask.&lt;/p&gt;
&lt;p&gt;And honestly, it is a good question.&lt;/p&gt;
&lt;p&gt;.NET already has &lt;code&gt;System.IO.Stream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why introduce another interface?&lt;/p&gt;
&lt;p&gt;Because I wasn't trying to replace &lt;code&gt;Stream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I was trying to establish a &lt;strong&gt;package-owned contract&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The current abstraction is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IBinaryStream : IDisposable
{
    bool CanRead { get; }
    bool CanWrite { get; }

    long Position { get; set; }
    long Length { get; }

    int Read(Span&amp;lt;byte&amp;gt; buffer);
    void Write(ReadOnlySpan&amp;lt;byte&amp;gt; buffer);
    void Flush();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what isn't there.&lt;/p&gt;
&lt;p&gt;There is no:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;filename;&lt;/li&gt;
&lt;li&gt;filesystem;&lt;/li&gt;
&lt;li&gt;database;&lt;/li&gt;
&lt;li&gt;socket;&lt;/li&gt;
&lt;li&gt;HTTP request;&lt;/li&gt;
&lt;li&gt;serializer registry;&lt;/li&gt;
&lt;li&gt;schema;&lt;/li&gt;
&lt;li&gt;domain metadata;&lt;/li&gt;
&lt;li&gt;application configuration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The interface only knows about the byte boundary.&lt;/p&gt;
&lt;p&gt;That makes it possible to write serialization code against one very small abstraction while letting the host decide what is actually underneath it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-two-directions-matter&quot;&gt;The Two Directions Matter&lt;/h2&gt;
&lt;p&gt;There is another small decision here that I think becomes important in larger systems.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1800093087783163459&quot; alt=&quot;Ai generated image showing data flow in both directions&quot;&gt;&lt;/p&gt;
&lt;p&gt;Packing and unpacking are separate capabilities.&lt;/p&gt;
&lt;p&gt;A type that produces a representation does not necessarily need to consume one.&lt;/p&gt;
&lt;p&gt;Likewise, a type may consume a representation without being responsible for producing it.&lt;/p&gt;
&lt;p&gt;So the package doesn't force everything into one giant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Serialize()
Deserialize()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;method.&lt;/p&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 DOMAIN TYPE
                     │
          ┌──────────┴──────────┐
          │                     │
        Pack                  Unpack
          │                     │
          ▼                     ▲
     representation          representation
          │                     │
          └──────────┬──────────┘
                     ▼
                byte stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That separation costs almost nothing.&lt;/p&gt;
&lt;p&gt;But architecturally, it keeps the ownership visible.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-smallest-possible-example&quot;&gt;The Smallest Possible Example&lt;/h2&gt;
&lt;p&gt;Let's make this concrete.&lt;/p&gt;
&lt;p&gt;Imagine a domain object containing one integer.&lt;/p&gt;
&lt;p&gt;That's intentionally boring.&lt;/p&gt;
&lt;p&gt;Boring is useful here because it lets us see exactly where the responsibility lives.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using System.Buffers.Binary;
using TheSingularityWorkshop.FSM_Serialization;

public sealed class ExampleState : IBinarySerializable
{
    public int Value { get; private set; }

    public ExampleState()
    {
    }

    public ExampleState(int value)
    {
        Value = value;
    }

    public void Pack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        BinaryPrimitives.WriteInt32LittleEndian(
            buffer,
            Value);

        stream.Write(buffer);
    }

    public void Unpack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        if (stream.Read(buffer) != buffer.Length)
            throw new EndOfStreamException();

        Value = BinaryPrimitives.ReadInt32LittleEndian(buffer);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no serializer object.&lt;/p&gt;
&lt;p&gt;There is no configuration object.&lt;/p&gt;
&lt;p&gt;There is no registration step.&lt;/p&gt;
&lt;p&gt;The object itself defines its representation.&lt;/p&gt;
&lt;p&gt;That is intentional.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;now-let-s-actually-serialize-something&quot;&gt;Now Let's Actually Serialize Something&lt;/h2&gt;
&lt;p&gt;The package includes &lt;code&gt;MemoryBinaryStream&lt;/code&gt;, which is exactly what it sounds like: an in-memory implementation of &lt;code&gt;IBinaryStream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That makes testing and temporary representations extremely simple.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var stream = new MemoryBinaryStream();

var original = new ExampleState(42);

original.Pack(stream);

stream.Position = 0;

var restored = new ExampleState();

restored.Unpack(stream);

Console.WriteLine(restored.Value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it.&lt;/p&gt;
&lt;p&gt;The complete flow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     │ Pack
     ▼
MemoryBinaryStream
     │
     │ bytes
     ▼
Position = 0
     │
     │ Unpack
     ▼
ExampleState
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The package supplied the boundary.&lt;/p&gt;
&lt;p&gt;The domain supplied the meaning.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;installation&quot;&gt;Installation&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=9594257162961760962&quot; alt=&quot;Ai generated image showing how easy it is to install&quot;&gt;&lt;/p&gt;
&lt;p&gt;If you want to try it yourself, the current published alpha is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package TheSingularityWorkshop.FSM_Serialization --version 0.1.0-alpha.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;PackageReference
    Include=&quot;TheSingularityWorkshop.FSM_Serialization&quot;
    Version=&quot;0.1.0-alpha.2&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The current package targets .NET 8.&lt;/p&gt;
&lt;p&gt;This is intentionally an alpha release.&lt;/p&gt;
&lt;p&gt;That doesn't mean the contracts are casual.&lt;/p&gt;
&lt;p&gt;Quite the opposite.&lt;/p&gt;
&lt;p&gt;A binary representation is one of those things where small assumptions can become enormous compatibility problems later.&lt;/p&gt;
&lt;p&gt;So I'm treating the package as alpha while treating its architectural boundaries seriously.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-about-real-files-&quot;&gt;What About Real Files?&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5919042162865156260&quot; alt=&quot;Ai generated image showing that we are now going to talk about real files&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is where &lt;code&gt;StreamBinaryStream&lt;/code&gt; enters.&lt;/p&gt;
&lt;p&gt;The package includes an adapter around an ordinary .NET &lt;code&gt;Stream&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var file = File.OpenRead(&quot;state.bin&quot;);
using var stream = new StreamBinaryStream(file);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the same domain serialization code can operate against the package abstraction without knowing that a file exists.&lt;/p&gt;
&lt;p&gt;The relationship is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     ▼
IBinaryStream
     │
     ▼
StreamBinaryStream
     │
     ▼
System.IO.Stream
     │
     ▼
file / memory / whatever the host provides
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is a very different architecture from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ExampleState
     │
     ▼
ExampleStateSerializer
     │
     ▼
FileSystemSerializerManager
     │
     ▼
ApplicationStorageService
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The latter might be useful somewhere.&lt;/p&gt;
&lt;p&gt;It just doesn't belong in this package.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-stream-adapter-is-supposed-to-be-boring&quot;&gt;The Stream Adapter Is Supposed to Be Boring&lt;/h2&gt;
&lt;p&gt;This is one of those cases where boring code is a compliment.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;StreamBinaryStream&lt;/code&gt; essentially delegates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public int Read(Span&amp;lt;byte&amp;gt; buffer)
    =&amp;gt; _stream.Read(buffer);

public void Write(ReadOnlySpan&amp;lt;byte&amp;gt; buffer)
    =&amp;gt; _stream.Write(buffer);

public void Flush()
    =&amp;gt; _stream.Flush();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's not a weakness.&lt;/p&gt;
&lt;p&gt;It's the point.&lt;/p&gt;
&lt;p&gt;The adapter is not supposed to invent behavior.&lt;/p&gt;
&lt;p&gt;It establishes a stable contract between two layers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;then-i-ran-into-a-very-important-detail&quot;&gt;Then I Ran Into a Very Important Detail&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11766274087907501333&quot; alt=&quot;Ai generated image illustrating a very important detail&quot;&gt;&lt;/p&gt;
&lt;p&gt;A byte stream is not the same thing as an array.&lt;/p&gt;
&lt;p&gt;This matters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int count = stream.Read(buffer);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You cannot blindly assume:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;count == buffer.Length
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a fixed-width decoder in FSM_Serialization explicitly checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;if (stream.Read(buffer) != buffer.Length)
    throw new EndOfStreamException();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That may look like a tiny implementation detail.&lt;/p&gt;
&lt;p&gt;It isn't.&lt;/p&gt;
&lt;p&gt;It establishes an architectural rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Never interpret an incomplete representation as a complete value.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The serializer shouldn't quietly manufacture missing data.&lt;/p&gt;
&lt;p&gt;If the representation is incomplete, the domain implementation gets to decide what that means.&lt;/p&gt;
&lt;p&gt;In the current examples, that means rejecting it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;serialization-is-not-storage&quot;&gt;Serialization Is Not Storage&lt;/h2&gt;
&lt;p&gt;This distinction became increasingly important as the Workshop architecture grew.&lt;/p&gt;
&lt;p&gt;Consider these questions:&lt;/p&gt;
&lt;h4 id=&quot;serialization&quot;&gt;Serialization&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;How are these values represented as bytes?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;storage&quot;&gt;Storage&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;Where do those bytes live?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;transport&quot;&gt;Transport&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;How do those bytes move between boundaries?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14879660735867442002&quot; alt=&quot;Ai generated image showing a difference between Transport and Storage&quot;&gt;&lt;/p&gt;
&lt;p&gt;Those are three different questions.&lt;/p&gt;
&lt;p&gt;So:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 SEMANTIC DATA
                      │
                      ▼
                representation
                      │
                      ▼
              FSM_Serialization
                      │
              ┌───────┴───────┐
              ▼               ▼
           storage         transport
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A database is not a serializer.&lt;/p&gt;
&lt;p&gt;A filesystem is not a serializer.&lt;/p&gt;
&lt;p&gt;An HTTP endpoint is not a serializer.&lt;/p&gt;
&lt;p&gt;A cache is not a serializer.&lt;/p&gt;
&lt;p&gt;They may all consume serialized data.&lt;/p&gt;
&lt;p&gt;That doesn't make them the same architectural layer.&lt;/p&gt;
&lt;p&gt;This is exactly the kind of distinction that seems unnecessary when an application is small and becomes incredibly valuable when an ecosystem starts growing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-binary-&quot;&gt;Why Binary?&lt;/h2&gt;
&lt;p&gt;I am not claiming that binary is universally superior to JSON.&lt;/p&gt;
&lt;p&gt;It isn't.&lt;/p&gt;
&lt;p&gt;JSON is fantastic for many things.&lt;/p&gt;
&lt;p&gt;Text is fantastic for many things.&lt;/p&gt;
&lt;p&gt;Human-readable configuration is valuable.&lt;/p&gt;
&lt;p&gt;Debugging matters.&lt;/p&gt;
&lt;p&gt;Authoring matters.&lt;/p&gt;
&lt;p&gt;Interoperability with external systems matters.&lt;/p&gt;
&lt;p&gt;The reason I created this package is narrower.&lt;/p&gt;
&lt;p&gt;There are situations where I want an explicit, compact, byte-oriented representation.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;runtime state;&lt;/li&gt;
&lt;li&gt;manifests;&lt;/li&gt;
&lt;li&gt;dependency metadata;&lt;/li&gt;
&lt;li&gt;compact configuration;&lt;/li&gt;
&lt;li&gt;cache entries;&lt;/li&gt;
&lt;li&gt;identity records;&lt;/li&gt;
&lt;li&gt;version records;&lt;/li&gt;
&lt;li&gt;lineage metadata;&lt;/li&gt;
&lt;li&gt;persistence;&lt;/li&gt;
&lt;li&gt;transport payloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The important thing is that the semantic model shouldn't have to become identical to its wire representation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;deterministic-does-not-mean-magical&quot;&gt;Deterministic Does Not Mean Magical&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=2047862534182526687&quot; alt=&quot;Ai generated image showing this isn't magical&quot;&gt;&lt;/p&gt;
&lt;p&gt;The package's NuGet description talks about deterministic binary serialization.&lt;/p&gt;
&lt;p&gt;There is an important qualification.&lt;/p&gt;
&lt;p&gt;Implementing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;IBinaryPackable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;does not magically make your representation deterministic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You&lt;/strong&gt; have to define deterministic rules.&lt;/p&gt;
&lt;p&gt;For example, suppose your object contains a collection that is semantically unordered.&lt;/p&gt;
&lt;p&gt;If you serialize it in whatever order happens to be present at runtime, two semantically identical objects could produce different byte sequences.&lt;/p&gt;
&lt;p&gt;If deterministic bytes matter, the domain needs something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic collection
        │
        ▼
canonical ordering
        │
        ▼
explicit representation
        │
        ▼
deterministic bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That distinction matters if bytes are later used for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;caching;&lt;/li&gt;
&lt;li&gt;content identity;&lt;/li&gt;
&lt;li&gt;synchronization;&lt;/li&gt;
&lt;li&gt;comparison;&lt;/li&gt;
&lt;li&gt;reproducible builds;&lt;/li&gt;
&lt;li&gt;lineage;&lt;/li&gt;
&lt;li&gt;integrity checks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Again, the package supplies the boundary.&lt;/p&gt;
&lt;p&gt;It does not pretend to know your domain's canonical ordering rules.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;versioning-gets-interesting&quot;&gt;Versioning Gets Interesting&lt;/h2&gt;
&lt;p&gt;This is another reason I didn't want a magical object serializer.&lt;/p&gt;
&lt;p&gt;Once bytes leave your process, they can outlive the code that created them.&lt;/p&gt;
&lt;p&gt;Imagine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Application v1
      │
      ▼
    bytes
      │
      │ time passes
      ▼
Application v2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What happens?&lt;/p&gt;
&lt;p&gt;That is a representation contract question.&lt;/p&gt;
&lt;p&gt;A durable binary format may need to distinguish between:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic object version
format version
dependency version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those are not necessarily the same thing.&lt;/p&gt;
&lt;p&gt;Your semantic object can change while the representation remains compatible.&lt;/p&gt;
&lt;p&gt;Your representation can change while the semantic concept remains the same.&lt;/p&gt;
&lt;p&gt;A dependency can change independently of both.&lt;/p&gt;
&lt;p&gt;FSM_Serialization intentionally doesn't impose one universal versioning strategy.&lt;/p&gt;
&lt;p&gt;Different domains need different compatibility policies.&lt;/p&gt;
&lt;p&gt;What it does is make the boundary explicit enough that you can actually have that conversation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-package-came-from-a-real-application&quot;&gt;The Package Came From a Real Application&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14666232440140458644&quot; alt=&quot;Ai generate image showing that the FSM_Serialization came from a real application&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is probably the most important part of the story.&lt;/p&gt;
&lt;p&gt;FSM_Serialization wasn't created because I thought:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;The world needs another serialization library.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are already plenty of serialization libraries.&lt;/p&gt;
&lt;p&gt;The package emerged from building the Workshop itself.&lt;/p&gt;
&lt;p&gt;The development pattern was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;working application
       │
       │ repeated infrastructure appears
       ▼
explicit contract
       │
       │ extract
       ▼
reusable package
       │
       │ consume again
       ▼
working application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;p&gt;I wasn't trying to abstract everything before I had a reason to abstract it.&lt;/p&gt;
&lt;p&gt;The application became the proving ground.&lt;/p&gt;
&lt;p&gt;Once the boundary became clear, I extracted it.&lt;/p&gt;
&lt;p&gt;That is a much healthier way to create infrastructure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;and-this-is-where-fsm-cos-enters-the-story&quot;&gt;And This Is Where FSM_COS Enters the Story&lt;/h2&gt;
&lt;p&gt;The larger ecosystem is becoming increasingly compositional.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10200316983152966329&quot; alt=&quot;Ai generated image showing that FSM_COS has arrived&quot;&gt;&lt;/p&gt;
&lt;p&gt;FSM_COS is concerned with questions like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What runtime composition was requested?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;Which MicroBundles are required?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;What dependencies are reachable?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;How do those capabilities reconcile?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;When is the composition stable enough to hand to a host?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;FSM_Serialization answers a different question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;How do we represent the values that need to cross a byte boundary?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That means a future conceptual flow can look something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Runtime Manifest
      │
      ▼
    FSM_COS
      │
      │ composition
      ▼
Runtime Assembly
      │
      │ representation
      ▼
FSM_Serialization
      │
      ▼
     bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then those bytes might eventually go somewhere else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 FSM_Serialization
                        │
             ┌──────────┼──────────┐
             ▼          ▼          ▼
          storage    transport    cache
             │          │          │
             └──────────┼──────────┘
                        ▼
                      host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The serializer doesn't become FSM_COS.&lt;/p&gt;
&lt;p&gt;FSM_COS doesn't become a serializer.&lt;/p&gt;
&lt;p&gt;That separation is exactly what I'm trying to preserve.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-tests-tell-the-same-story&quot;&gt;The Tests Tell the Same Story&lt;/h2&gt;
&lt;p&gt;The package currently has a deliberately small test surface.&lt;/p&gt;
&lt;p&gt;It verifies things such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an empty &lt;code&gt;MemoryBinaryStream&lt;/code&gt; is readable and writable;&lt;/li&gt;
&lt;li&gt;bytes survive a write/read round trip;&lt;/li&gt;
&lt;li&gt;existing byte arrays can initialize a memory stream;&lt;/li&gt;
&lt;li&gt;null input is rejected;&lt;/li&gt;
&lt;li&gt;end-of-input returns zero;&lt;/li&gt;
&lt;li&gt;positioning and overwriting work;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;StreamBinaryStream&lt;/code&gt; delegates correctly;&lt;/li&gt;
&lt;li&gt;null wrapped streams are rejected;&lt;/li&gt;
&lt;li&gt;writes reach the underlying stream;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IBinaryPackable&lt;/code&gt; and &lt;code&gt;IBinaryUnpackable&lt;/code&gt; can round-trip;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IBinarySerializable&lt;/code&gt; combines both contracts;&lt;/li&gt;
&lt;li&gt;incomplete binary input is rejected.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of the tests is essentially the entire architectural idea in executable form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var original = new TestValue(123456789);

using var stream = new MemoryBinaryStream();

original.Pack(stream);

stream.Position = 0;

var restored = new TestValue();

restored.Unpack(stream);

Assert.Equal(original.Value, restored.Value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tests aren't merely protecting the implementation here.&lt;/p&gt;
&lt;p&gt;They are defining the boundary.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;i-also-benchmarked-it&quot;&gt;I Also Benchmarked It&lt;/h2&gt;
&lt;p&gt;Because eventually somebody is going to ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Okay, but what does that abstraction cost?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Good.&lt;/p&gt;
&lt;p&gt;Let's measure it.&lt;/p&gt;
&lt;p&gt;The companion benchmark project uses BenchmarkDotNet against .NET 8 and exercises payloads from 16 bytes through 100,000 bytes.&lt;/p&gt;
&lt;p&gt;The interesting result isn't that FSM_Serialization magically beats &lt;code&gt;MemoryStream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It doesn't.&lt;/p&gt;
&lt;p&gt;That's not what I was trying to prove.&lt;/p&gt;
&lt;p&gt;The useful result is that the abstraction can remain extremely close to the underlying primitive for substantial workloads.&lt;/p&gt;
&lt;p&gt;At 100,000 bytes, the benchmark measured approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MemoryStream write:
39,894.85 ns

MemoryBinaryStream write:
40,005.59 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's effectively the same scale.&lt;/p&gt;
&lt;p&gt;Likewise, the adapter path was approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MemoryStream read:
2,272.47 ns

StreamBinaryStream read:
2,298.40 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Again, very close.&lt;/p&gt;
&lt;p&gt;And the end-to-end &lt;code&gt;IBinarySerializable&lt;/code&gt; benchmark measured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;16 bytes       64.08 ns
1,024 bytes   207.47 ns
10,000 bytes  1,481.61 ns
100,000 bytes 60,152.74 ns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there is an important qualification.&lt;/p&gt;
&lt;p&gt;The benchmark measures the &lt;em&gt;whole operation&lt;/em&gt;, including things like creating the test value, creating the stream, packing, rewinding, allocating the destination payload, and unpacking.&lt;/p&gt;
&lt;p&gt;So I'm not going to pretend those numbers represent some universal &quot;serialization speed.&quot;&lt;/p&gt;
&lt;p&gt;They represent a measured workload.&lt;/p&gt;
&lt;p&gt;That's much more useful.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-benchmark-question-i-actually-care-about&quot;&gt;The Benchmark Question I Actually Care About&lt;/h2&gt;
&lt;p&gt;The interesting engineering question isn't:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Is my interface faster than MemoryStream?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The interface isn't supposed to be the star of the show.&lt;/p&gt;
&lt;p&gt;The question is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Can I establish a useful architectural boundary without making the underlying operation prohibitively expensive?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The current measurements suggest that, for these workloads, the answer is yes.&lt;/p&gt;
&lt;p&gt;And that's exactly where I want infrastructure to live.&lt;/p&gt;
&lt;p&gt;The abstraction should be visible architecturally and nearly invisible computationally.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-deliberately-did-not-build&quot;&gt;What I Deliberately Did Not Build&lt;/h2&gt;
&lt;p&gt;This may be the most important section of the entire package.&lt;/p&gt;
&lt;p&gt;FSM_Serialization should not become:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an FSM engine;&lt;/li&gt;
&lt;li&gt;a game framework;&lt;/li&gt;
&lt;li&gt;a GUI framework;&lt;/li&gt;
&lt;li&gt;a browser framework;&lt;/li&gt;
&lt;li&gt;a Unity integration layer;&lt;/li&gt;
&lt;li&gt;a database abstraction;&lt;/li&gt;
&lt;li&gt;a filesystem framework;&lt;/li&gt;
&lt;li&gt;an HTTP client;&lt;/li&gt;
&lt;li&gt;an application configuration system;&lt;/li&gt;
&lt;li&gt;a MicroBundle implementation;&lt;/li&gt;
&lt;li&gt;an Experience runtime;&lt;/li&gt;
&lt;li&gt;a reflection-driven object graph serializer;&lt;/li&gt;
&lt;li&gt;a global serializer registry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those things may all be useful.&lt;/p&gt;
&lt;p&gt;They just aren't this thing.&lt;/p&gt;
&lt;p&gt;That restraint is part of the architecture.&lt;/p&gt;
&lt;p&gt;A small package becomes powerful when other systems can depend on it without having to inherit the systems it was never supposed to understand.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;so-what-would-you-actually-use-it-for-&quot;&gt;So What Would You Actually Use It For?&lt;/h2&gt;
&lt;p&gt;If you're building a .NET application and you have a domain type whose state needs to cross a byte-oriented boundary, the starting point is incredibly small.&lt;/p&gt;
&lt;p&gt;Install the package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package TheSingularityWorkshop.FSM_Serialization --version 0.1.0-alpha.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Define your representation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class MyState : IBinarySerializable
{
    public int Id { get; set; }

    public void Pack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        BinaryPrimitives.WriteInt32LittleEndian(
            buffer,
            Id);

        stream.Write(buffer);
    }

    public void Unpack(IBinaryStream stream)
    {
        Span&amp;lt;byte&amp;gt; buffer = stackalloc byte[sizeof(int)];

        if (stream.Read(buffer) != buffer.Length)
            throw new EndOfStreamException();

        Id = BinaryPrimitives.ReadInt32LittleEndian(buffer);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use memory when you need a temporary representation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var stream = new MemoryBinaryStream();

state.Pack(stream);

byte[] bytes = stream.ToArray();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use a normal .NET stream when the host provides one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var file = File.Create(&quot;state.bin&quot;);
using var stream = new StreamBinaryStream(file);

state.Pack(stream);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that's basically the entire learning curve.&lt;/p&gt;
&lt;p&gt;The difficult part isn't learning the API.&lt;/p&gt;
&lt;p&gt;The difficult part is deciding what your domain actually means by its representation.&lt;/p&gt;
&lt;p&gt;I think that's exactly where the difficult part belongs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-serializer-shouldn-t-become-your-architecture&quot;&gt;A Serializer Shouldn't Become Your Architecture&lt;/h2&gt;
&lt;p&gt;This is the idea that kept resurfacing while I built the package.&lt;/p&gt;
&lt;p&gt;It's very easy for infrastructure to become the thing it was supposed to merely support.&lt;/p&gt;
&lt;p&gt;A serializer starts storing metadata.&lt;/p&gt;
&lt;p&gt;Then it starts deciding schemas.&lt;/p&gt;
&lt;p&gt;Then it starts managing versions.&lt;/p&gt;
&lt;p&gt;Then it starts talking to databases.&lt;/p&gt;
&lt;p&gt;Then it starts managing transports.&lt;/p&gt;
&lt;p&gt;Then it starts discovering domain types.&lt;/p&gt;
&lt;p&gt;Eventually your &quot;serialization library&quot; is quietly running your architecture.&lt;/p&gt;
&lt;p&gt;I wanted the opposite.&lt;/p&gt;
&lt;p&gt;I wanted something I could put underneath the architecture and forget about until I actually needed to cross the boundary.&lt;/p&gt;
&lt;p&gt;So the package can be reduced to one equation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;semantic state
      +
explicit representation rules
      +
byte boundary
      =
portable representation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;object
  +
magic serializer
  =
architecture
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the difference.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-boundary-i-want-to-preserve&quot;&gt;The Boundary I Want to Preserve&lt;/h2&gt;
&lt;p&gt;The entire package can ultimately be reduced to this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                 DOMAIN
                   │
          owns meaning and rules
                   │
                   ▼
          Pack / Unpack contract
                   │
                   ▼
          FSM_Serialization
                   │
              byte boundary
                   │
        ┌──────────┴──────────┐
        ▼                     ▼
     storage               transport
        │                     │
        └──────────┬──────────┘
                   ▼
                  HOST
                   │
             manifestation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The serializer owns the boundary.&lt;/p&gt;
&lt;p&gt;The domain owns the meaning.&lt;/p&gt;
&lt;p&gt;The host owns the destination.&lt;/p&gt;
&lt;p&gt;That is the architecture.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-i-think-small-infrastructure-is-worth-talking-about&quot;&gt;Why I Think Small Infrastructure Is Worth Talking About&lt;/h2&gt;
&lt;p&gt;There is a tendency in software writing to focus on the enormous systems.&lt;/p&gt;
&lt;p&gt;The massive framework.&lt;/p&gt;
&lt;p&gt;The clever algorithm.&lt;/p&gt;
&lt;p&gt;The huge benchmark.&lt;/p&gt;
&lt;p&gt;The complicated application.&lt;/p&gt;
&lt;p&gt;But some of the most consequential architectural decisions are tiny.&lt;/p&gt;
&lt;p&gt;An interface.&lt;/p&gt;
&lt;p&gt;A boundary.&lt;/p&gt;
&lt;p&gt;A decision about ownership.&lt;/p&gt;
&lt;p&gt;A decision about what a component &lt;strong&gt;doesn't&lt;/strong&gt; know.&lt;/p&gt;
&lt;p&gt;FSM_Serialization is one of those experiments.&lt;/p&gt;
&lt;p&gt;It isn't trying to replace every serialization technology in .NET.&lt;/p&gt;
&lt;p&gt;It isn't trying to declare that binary is the future.&lt;/p&gt;
&lt;p&gt;It is much more modest:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I needed a clean byte boundary for a growing compositional architecture, so I built the smallest one I could.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Then I tested it.&lt;/p&gt;
&lt;p&gt;Then I benchmarked it.&lt;/p&gt;
&lt;p&gt;Then I packaged it.&lt;/p&gt;
&lt;p&gt;And now I'm putting it out there so somebody else can kick it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;you-don-t-have-to-take-my-word-for-it&quot;&gt;You Don't Have to Take My Word for It&lt;/h2&gt;
&lt;p&gt;That's ultimately why I publish these projects.&lt;/p&gt;
&lt;p&gt;Install it.&lt;/p&gt;
&lt;p&gt;Implement one type.&lt;/p&gt;
&lt;p&gt;Serialize something you actually care about.&lt;/p&gt;
&lt;p&gt;Put it under your own workload.&lt;/p&gt;
&lt;p&gt;Decide whether the ownership model makes sense to you.&lt;/p&gt;
&lt;p&gt;Measure it.&lt;/p&gt;
&lt;p&gt;Break it.&lt;/p&gt;
&lt;p&gt;Tell me where the boundary doesn't hold.&lt;/p&gt;
&lt;p&gt;That's much more interesting to me than somebody simply agreeing with the architecture.&lt;/p&gt;
&lt;p&gt;The package is available now as an alpha.&lt;/p&gt;
&lt;p&gt;And if you find a place where this tiny boundary lets you remove a surprisingly large amount of architectural mess, I'd genuinely like to hear about it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;resources-amp-code-&quot;&gt;Resources &amp;amp; Code:&lt;/h3&gt;
&lt;p&gt;The FSM Package (Unity Asset Store):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://assetstore.unity.com/packages/slug/332450&quot;&gt;https://assetstore.unity.com/packages/slug/332450&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;nuget-packages-&quot;&gt;NuGet Packages:&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&quot;&gt;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_Serialization/&quot;&gt;https://www.nuget.org/packages/TheSingularityWorkshop.FSM_Serialization/&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;github-repositories-&quot;&gt;GitHub Repositories:&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/FSM_API&quot;&gt;https://github.com/TrentBest/FSM_API&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/TrentBest/TheSingularityWorkshop.FSM_Serialization&quot;&gt;https://github.com/TrentBest/TheSingularityWorkshop.FSM_Serialization&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Support Our Work:&lt;/p&gt;
&lt;p&gt;Patreon Page:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/c/TheSingularityWorkshop&quot;&gt;https://www.patreon.com/c/TheSingularityWorkshop&lt;/a&gt;&lt;/p&gt;
&lt;p&gt; Support Us (PayPal Donation):&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&quot;&gt;https://www.paypal.com/donate/?hosted_button_id=3Z7263LCQMV9J&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We'd love to hear your thoughts! Please Like this post, Love the code, and Share your feedback in the comments.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14669829065118869161&quot; alt=&quot;A Hyper-Detailed, Architecturally Impossible Synthesis of Consciousness and Digital Matter. The image is a frenetic, deeply complex digital vista where a central Luminous Eye dominates a vast, glowing circuit landscape. This eye, suspended mid-air, is a sphere of intense, fractal energy—a pulsating vortex of pink, violet, and electric blue light—that powerfully suggests an emergence of digital consciousness or a Technological Singularity itself. The core is a bottomless black aperture, ringed by a white-hot plasma disc. Below this ocular energy source, the light dissipates into an intricate, copper-gold and neon-blue Circuit Board Megastructure that stretches to the horizon, impossibly dense with exaggerated microchips, glowing resistor arrays, and power conduits that form deep, glowing canyons. The background is a dark, holographic projection field displaying complex schematics, mathematical models, and flowing data streams, reinforcing the theme of Absolute Digital Engineering. The entire scene is bathed in a dramatic, opposing light source—a warm, orange-gold glow from the left and an ice-cold, electric-blue glow from the right—creating a maximalist, high-contrast visual experience that definitively rejects the minimalist simplicity of conventional design and announces: Technology is intricate, overwhelming, and infinitely complex.&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28979/i-built-a-serializer-that-refuses-to-own-your-data</guid>
<pubDate>Mon, 28 Sep 2026 23:36:42 +0000</pubDate>
</item>
<item>
<title>Design the Cursor, Not Just the Query</title>
<link>https://coderlegion.com/28877/design-the-cursor-not-just-the-query</link>
<description>&lt;p&gt;Pagination is often introduced as a performance improvement: fetch fewer rows, render less markup, and keep response sizes predictable. That is only half the job.&lt;/p&gt;
&lt;p&gt;For an operational queue that changes while people work, pagination also defines what it means to continue reading. If that contract is weak, the database can answer every query correctly while the person using the screen still sees duplicates or misses work.&lt;/p&gt;
&lt;p&gt;The important design question is not merely, “How many rows should this query return?” It is, “What does the next request mean after the underlying set has changed?”&lt;/p&gt;
&lt;h3 id=&quot;why-offset-paging-drifts&quot;&gt;Why offset paging drifts&lt;/h3&gt;
&lt;p&gt;Consider a queue ordered newest first. A reviewer asks for the first twenty-five rows. The next request uses an offset of twenty-five.&lt;/p&gt;
&lt;p&gt;Between those requests, a new row arrives at the top. Every earlier row has moved down one position. The second query now begins with the final row from page one, so the reviewer sees a duplicate. More importantly, one unseen row has been pushed beyond the requested range.&lt;/p&gt;
&lt;p&gt;This is not an exotic race. Append-mostly queues invite it because adding work is the ordinary operation. Offset paging treats positions as stable even though insertion changes every position below it.&lt;/p&gt;
&lt;p&gt;Mutable sorting creates a similar problem. If the queue is ordered by status or last-updated time, an operator’s decision can move a row while the walk is in progress. A continuation based on that value inherits the instability of the workflow.&lt;/p&gt;
&lt;h3 id=&quot;continue-from-an-immutable-boundary&quot;&gt;Continue from an immutable boundary&lt;/h3&gt;
&lt;p&gt;A stronger contract says: continue after the boundary returned by the previous read.&lt;/p&gt;
&lt;p&gt;For a newest-first queue, that boundary can be an immutable creation or submission timestamp. New rows arriving above the boundary are not part of the current walk. They will appear when the reviewer starts again from the top, but they do not shift the meaning of “next.”&lt;/p&gt;
&lt;p&gt;Equal timestamps need deliberate handling. Adding an identifier as a tie-breaker becomes fragile when data providers sort that type differently.&lt;/p&gt;
&lt;p&gt;Another approach is to carry the boundary timestamp plus the number of rows already consumed at that exact timestamp. The database remains responsible for its native ordering within the tie, while the continuation records how far through that tie the walk has progressed. The detail matters less than the invariant: ties must neither duplicate nor hide rows.&lt;/p&gt;
&lt;h3 id=&quot;make-the-continuation-part-of-the-contract&quot;&gt;Make the continuation part of the contract&lt;/h3&gt;
&lt;p&gt;An opaque cursor should not be a loosely encoded page number. It is protocol state, so treat it that way.&lt;/p&gt;
&lt;p&gt;Useful safeguards include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a version, so the format can evolve deliberately;&lt;/li&gt;
&lt;li&gt;a strict length limit and parse validation;&lt;/li&gt;
&lt;li&gt;the immutable boundary and tie position;&lt;/li&gt;
&lt;li&gt;the active filter identity; and&lt;/li&gt;
&lt;li&gt;sensible bounds on any stored count.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Binding the cursor to its filter is especially important. A position in “all open items” does not mean the same thing in “items for one category.” Reusing the cursor after changing filters can silently skip work. Rejecting that request is clearer than pretending the cursor is still meaningful.&lt;/p&gt;
&lt;p&gt;Likewise, a malformed continuation should fail explicitly. Silently dropping it and returning the first page converts a bad request into a plausible-looking duplicate page. Operational software benefits from visible invalid states.&lt;/p&gt;
&lt;h3 id=&quot;keep-every-downstream-read-bounded&quot;&gt;Keep every downstream read bounded&lt;/h3&gt;
&lt;p&gt;Paging the primary table is not enough if enrichment still loads data for the entire result set.&lt;/p&gt;
&lt;p&gt;A common pattern reads the page, extracts its identifiers, and then fetches related summaries in a second query. That identifier list must come from the bounded page. Otherwise the visible query is paged while a hidden IN clause continues growing with the queue.&lt;/p&gt;
&lt;p&gt;Fetching one extra primary row is a simple way to determine whether a continuation is needed. Return the requested page size, and issue a next cursor only when the extra row exists. The presence of that cursor—not whether the current page happens to be full—becomes the authoritative “has more” signal.&lt;/p&gt;
&lt;p&gt;This also avoids an exact count on every read. For many live queues, “Page 3” plus Previous and Next is the more honest interface.&lt;/p&gt;
&lt;h3 id=&quot;carry-the-contract-into-the-blazor-ui&quot;&gt;Carry the contract into the Blazor UI&lt;/h3&gt;
&lt;p&gt;Server-side continuation paging should not be wrapped in a client component that first downloads the entire queue. The UI has to follow the same contract.&lt;/p&gt;
&lt;p&gt;A practical Blazor page can keep a small trail containing the cursor used to open each visited page. Next stores the returned cursor. Previous reuses the earlier cursor to re-read that logical page. After an operator records a decision, refresh the current page rather than throwing them back to the beginning.&lt;/p&gt;
&lt;p&gt;Page changes should also clear any pending confirmation tied to a row that is no longer visible. That is a small state-management detail with a large clarity benefit: the user should never be asked to confirm an action on an item they cannot see.&lt;/p&gt;
&lt;h3 id=&quot;test-the-movement-not-only-the-shape&quot;&gt;Test the movement, not only the shape&lt;/h3&gt;
&lt;p&gt;Happy-path tests that assert “twenty-five rows returned” do not prove a paging contract.&lt;/p&gt;
&lt;p&gt;The valuable cases exercise movement:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;insert a newer row after reading page one, then verify page two has no gap or duplicate;&lt;/li&gt;
&lt;li&gt;create several rows with the same boundary timestamp;&lt;/li&gt;
&lt;li&gt;reject zero, negative, and excessive page sizes;&lt;/li&gt;
&lt;li&gt;reject malformed, oversized, and filter-mismatched cursors;&lt;/li&gt;
&lt;li&gt;verify related-data queries receive only current-page identifiers;&lt;/li&gt;
&lt;li&gt;navigate Next and Previous in the component; and&lt;/li&gt;
&lt;li&gt;perform an action and confirm the current page is refreshed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests describe the promise a reviewer experiences, not merely an implementation detail.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-trade-off-1&quot;&gt;The honest trade-off&lt;/h3&gt;
&lt;p&gt;Continuation paging is more work than offset paging. It needs a stable sort, a cursor format, versioning, filter binding, careful tie semantics, and focused tests. It also makes random access and exact page counts less convenient.&lt;/p&gt;
&lt;p&gt;That cost is justified when the list is both live and operationally important. Bounded queries protect the system under bursty load; stable continuation protects the human workflow under concurrent change.&lt;/p&gt;
&lt;p&gt;Takeaway: choose pagination from the mutation pattern, not from the table widget. If ordinary inserts can change what “next” means, the continuation itself deserves first-class design.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28877/design-the-cursor-not-just-the-query</guid>
<pubDate>Mon, 28 Sep 2026 06:23:05 +0000</pubDate>
</item>
<item>
<title>When EF Core's Design-Time Model Is Supposed to Be Incomplete</title>
<link>https://coderlegion.com/28685/when-ef-cores-design-time-model-is-supposed-to-be-incomplete</link>
<description>&lt;p&gt;Framework warnings are useful because they compress hard-earned experience into an early signal. But a signal is not an invariant. In an extensible EF Core architecture, design-time tooling can legitimately see less of the model than the running application. If that difference is intentional, treating every pending-model warning as real drift can block a safe database update—or encourage a destructive migration.&lt;/p&gt;
&lt;p&gt;The answer is not to ignore the warning casually. It is to understand exactly why it is false, then replace the lost signal with checks that understand the real architecture.&lt;/p&gt;
&lt;h3 id=&quot;how-a-valid-model-becomes-invisible&quot;&gt;How a Valid Model Becomes Invisible&lt;/h3&gt;
&lt;p&gt;Consider a modular .NET application with a DbContext owned by a foundational module. Higher-level modules can contribute entity configurations through runtime discovery. This keeps the dependency direction clean: the foundational data module does not need references back to every feature that extends it.&lt;/p&gt;
&lt;p&gt;At runtime, the host loads the feature assemblies and the DbContext receives the complete set of configurations. At design time, the EF Core tooling starts from the foundational project. Loading a higher-level contributor from there would create a circular dependency, so the design-time process builds a smaller model.&lt;/p&gt;
&lt;p&gt;The migration snapshot still represents the complete model. The design-time differ compares that snapshot with its partial view and concludes that the contributed entity has disappeared. A probe migration may therefore contain a drop operation for a table that remains valid and necessary at runtime.&lt;/p&gt;
&lt;p&gt;The tool is behaving consistently with the inputs it can see. The architectural context is what it lacks.&lt;/p&gt;
&lt;h3 id=&quot;prove-the-false-positive-before-suppressing-it&quot;&gt;Prove the False Positive Before Suppressing It&lt;/h3&gt;
&lt;p&gt;Before changing warning behaviour, reduce the difference to something explainable.&lt;/p&gt;
&lt;p&gt;Generate a disposable probe migration and inspect every operation. If the only reported difference is the known extension entity that design time cannot load, you have evidence for a visibility gap. If the probe also changes columns, indexes, relationships, or unrelated tables, stop: that may be genuine drift.&lt;/p&gt;
&lt;p&gt;Then verify the full model independently. Run a snapshot comparison in a test process where all contributing assemblies are loaded. That test should compare the migration snapshot against the same model the application actually builds.&lt;/p&gt;
&lt;p&gt;Only after both checks agree should the design-time factory suppress the specific pending-model warning. The suppression belongs at the narrow boundary where the false positive occurs, not in a broad configuration that hides the warning for unrelated contexts.&lt;/p&gt;
&lt;p&gt;This is an important distinction: the warning is disabled because a stronger source of truth exists, not because the warning is inconvenient.&lt;/p&gt;
&lt;h3 id=&quot;turn-the-destructive-failure-mode-into-a-build-failure&quot;&gt;Turn the Destructive Failure Mode Into a Build Failure&lt;/h3&gt;
&lt;p&gt;Even after database update is unblocked, migration scaffolding may still use the incomplete design-time model. A future developer can generate a migration and receive a plausible-looking drop operation.&lt;/p&gt;
&lt;p&gt;Comments and runbooks help, but they do not stop a merge. An executable guard can.&lt;/p&gt;
&lt;p&gt;Inspect every migration's operations and fail if a protected extension table is dropped, renamed, or destructively altered. Cover structured operations such as table drops and column drops, but also inspect raw SQL for equivalent destructive statements. The goal is not a general SQL parser. It is a focused tripwire for the known architectural blind spot.&lt;/p&gt;
&lt;p&gt;Add a positive control as well. The suite should prove that migration discovery found the migration which creates the protected table. Without that check, a broken scanner could inspect zero migrations and report a reassuring green result. A rejection-only test cannot distinguish “nothing is wrong” from “nothing ran.”&lt;/p&gt;
&lt;p&gt;That small positive assertion dramatically improves the credibility of the safety test.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-test-harness-honest&quot;&gt;Keep the Test Harness Honest&lt;/h3&gt;
&lt;p&gt;Design-time factory tests often need temporary configuration, such as a process-wide environment variable containing a connection string. Two test classes that mutate the same variable can race: one restores the original value while the other is still constructing its context.&lt;/p&gt;
&lt;p&gt;That creates a flaky failure which disappears when either class runs alone.&lt;/p&gt;
&lt;p&gt;Treat process-wide configuration as shared mutable state. Put those tests in one serialized collection, restore the previous value in a &lt;code&gt;finally&lt;/code&gt; block, and make it explicit that the connection value is only used to construct options when no database call is required.&lt;/p&gt;
&lt;p&gt;This detail is adjacent to the model problem, but it matters. A safety test that flakes trains people to distrust or bypass it.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-custom-safety-work&quot;&gt;The Trade-Off Is Custom Safety Work&lt;/h3&gt;
&lt;p&gt;This approach is not free. The team must maintain a full-model snapshot test, a focused migration-operation guard, a positive control, serialized environment-sensitive tests, and documentation explaining which CLI signal is intentionally unreliable for this context.&lt;/p&gt;
&lt;p&gt;The alternative is worse: reverse the dependency direction, abandon modular extension points, block legitimate database updates, or risk committing a destructive migration produced by a partial model.&lt;/p&gt;
&lt;p&gt;The general rule is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Identify why the generic check is wrong.&lt;/li&gt;
&lt;li&gt;Prove the exact boundary of the false positive.&lt;/li&gt;
&lt;li&gt;Suppress only at that boundary.&lt;/li&gt;
&lt;li&gt;Replace the lost signal with a stronger invariant.&lt;/li&gt;
&lt;li&gt;Add a positive control so the invariant cannot pass vacuously.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Framework defaults are valuable, but architecture-specific evidence is stronger. When design time and runtime intentionally see different worlds, make that difference explicit—and make the dangerous interpretation impossible to merge unnoticed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28685/when-ef-cores-design-time-model-is-supposed-to-be-incomplete</guid>
<pubDate>Sat, 26 Sep 2026 06:02:03 +0000</pubDate>
</item>
<item>
<title>A Page Is Not Shipped Until People Can Find It</title>
<link>https://coderlegion.com/28578/a-page-is-not-shipped-until-people-can-find-it</link>
<description>&lt;p&gt;A routed page can compile, pass its authorization checks, have documentation, and still be functionally absent. If the people entitled to use it cannot discover a normal way in, the product behaves as though the capability was never built.&lt;/p&gt;
&lt;p&gt;That is easy to miss in a modular Blazor application. One team adds pages in one project, another module owns a second page tree, and a central hub or navigation catalogue tries to present the whole product coherently. Each local change can look correct while the experience between modules quietly breaks.&lt;/p&gt;
&lt;p&gt;The remedy is not another release checklist. It is an executable discoverability contract.&lt;/p&gt;
&lt;h3 id=&quot;define-the-invariant-at-the-page-boundary&quot;&gt;Define the invariant at the page boundary&lt;/h3&gt;
&lt;p&gt;A useful rule is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Every static operational page must have one intentional entry point whose visibility matches the page's access rule.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;“Static” matters. A detail page with an identifier in its route is normally reached from a list, search result, or parent screen. It does not need a global menu item. A screen that answers on two route aliases is still one page; adding two cards would create duplication rather than discoverability.&lt;/p&gt;
&lt;p&gt;The rule should cover pages, not merely strings that resemble URLs. That keeps the test aligned with the product concept people actually use.&lt;/p&gt;
&lt;p&gt;It should also say “intentional entry point,” not “mentioned somewhere.” A link buried inside another page may technically make a route reachable, but it does not necessarily make the capability findable. Likewise, a help article that names a screen is documentation, not navigation. Requiring a deliberate card or menu entry forces the information architecture to be explicit.&lt;/p&gt;
&lt;h3 id=&quot;scan-every-module-not-only-the-original-tree&quot;&gt;Scan every module, not only the original tree&lt;/h3&gt;
&lt;p&gt;Architecture tests often begin with a useful local assumption: all administrative pages live under one directory, namespace, or assembly. The test passes for years. Then a new module introduces another tree and the guard continues to report green because it never sees the new pages.&lt;/p&gt;
&lt;p&gt;This is a particularly awkward failure because both the feature and the guard are “working as designed.” The design boundary is simply stale.&lt;/p&gt;
&lt;p&gt;The test therefore needs a completeness check as well as a matching check. Enumerate every known page tree. Parse the routed components. Exclude only clearly contextual routes. Then compare the resulting pages with the navigation catalogue.&lt;/p&gt;
&lt;p&gt;Add a sanity assertion too. If a mature tree suddenly produces zero—or far fewer—pages, the parser or directory assumption probably broke. Without that guard, a failed scan can look like perfect compliance.&lt;/p&gt;
&lt;p&gt;When a new module owns pages, extending the scan is part of integrating that module. This turns architectural coverage into an explicit responsibility rather than inherited folklore.&lt;/p&gt;
&lt;h3 id=&quot;keep-navigation-and-authorization-separate&quot;&gt;Keep navigation and authorization separate&lt;/h3&gt;
&lt;p&gt;The navigation entry should use the same access decision as the destination page. If the card is broader, people see an invitation that ends in refusal. If it is narrower, people who are entitled to use the page cannot find it.&lt;/p&gt;
&lt;p&gt;It is tempting to reuse a nearby permission because it is convenient. That can be wrong in both directions. When no existing decision expresses the page's real audience, adding a focused visibility flag is usually clearer than bending an unrelated one.&lt;/p&gt;
&lt;p&gt;But matching visibility does not turn navigation into security. A hidden card only changes what the interface presents. A direct request, a bookmarked address, or another client can still reach the endpoint. The page or server operation must continue to enforce authorization independently.&lt;/p&gt;
&lt;p&gt;This separation produces a healthy pair of invariants:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;navigation makes entitled capabilities discoverable;&lt;/li&gt;
&lt;li&gt;authorization rejects requests that are not entitled.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Testing both is stronger than asking either mechanism to do two jobs.&lt;/p&gt;
&lt;h3 id=&quot;prefer-a-precise-contract-over-linked-somewhere-&quot;&gt;Prefer a precise contract over “linked somewhere”&lt;/h3&gt;
&lt;p&gt;A broad reachability scan can be a good first diagnostic. Search the codebase for links to each route and identify pages that have no inbound reference. That quickly reveals obvious orphans.&lt;/p&gt;
&lt;p&gt;As a permanent contract, however, “linked somewhere” is weak. A tab rail is visible only after someone has already reached a page in that tab set. A contextual link may depend on data or state. A help page may mention the route without providing product navigation. Source text can prove that a string exists; it cannot prove that the intended audience has a clear entry point.&lt;/p&gt;
&lt;p&gt;Tightening the rule to require the chosen navigation catalogue makes the standard less ambiguous. It also creates useful pressure: when a page fits nowhere, the team must decide whether the navigation model is incomplete, the page is contextual, or the feature should not be exposed yet.&lt;/p&gt;
&lt;h3 id=&quot;accept-the-maintenance-cost-deliberately&quot;&gt;Accept the maintenance cost deliberately&lt;/h3&gt;
&lt;p&gt;The stricter contract adds work. New pages need catalogue entries, accurate visibility mapping, and suitable grouping. Route conventions must be understood well enough for the test to distinguish static pages, detail pages, and aliases. Large navigation surfaces may expose sections that no longer fit their historical categories.&lt;/p&gt;
&lt;p&gt;Those are not test problems to hide with a long exemption list. They are product and architecture decisions the test has made visible.&lt;/p&gt;
&lt;p&gt;Keep exemptions small, named, and justified by a navigation model rather than by inconvenience. Fail when an exemption becomes stale. If a page genuinely belongs behind a list or workflow, encode that reason. If it is an operational destination, give it an intentional entry point.&lt;/p&gt;
&lt;p&gt;The payoff is modest but durable: completed capabilities do not disappear between modules, permission drift becomes easier to see, and a new page cannot quietly land outside the product's navigation contract.&lt;/p&gt;
&lt;p&gt;A page is not shipped merely because its route exists. It is shipped when the right person can find it—and the wrong person still cannot execute it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28578/a-page-is-not-shipped-until-people-can-find-it</guid>
<pubDate>Fri, 25 Sep 2026 06:06:10 +0000</pubDate>
</item>
<item>
<title>A Retry Is Not Safe Until the Side Effect Is Safe</title>
<link>https://coderlegion.com/28427/a-retry-is-not-safe-until-the-side-effect-is-safe</link>
<description>&lt;p&gt;A retry can duplicate a side effect even when the original command completed successfully.&lt;/p&gt;
&lt;p&gt;This is easy to miss in a method that does two ordinary things: save a record and send a message. Each operation is understandable on its own. The difficult part is the uncertainty between them.&lt;/p&gt;
&lt;p&gt;Suppose an endpoint creates an invitation, persists it, asks an email provider to deliver a link, and returns a response. The database commit succeeds. The provider may accept the message. Then the network drops the response. The client cannot tell whether anything happened, so it retries.&lt;/p&gt;
&lt;p&gt;If the server repeats the whole method, it can create another record, another secret, or another email. The code has turned a lost response into duplicated authority-bearing side effects.&lt;/p&gt;
&lt;p&gt;The lesson is broader than invitations: design the durable commit point, provider boundary, and retry contract as one workflow.&lt;/p&gt;
&lt;h3 id=&quot;commit-the-fact-before-announcing-it&quot;&gt;Commit the fact before announcing it&lt;/h3&gt;
&lt;p&gt;Sending before saving creates the most confusing failure mode. A recipient receives a plausible link, but the backing operation never committed. To them, the system appears to reject a valid action for no visible reason.&lt;/p&gt;
&lt;p&gt;A safer ordering is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Validate the command.&lt;/li&gt;
&lt;li&gt;Create the durable operation.&lt;/li&gt;
&lt;li&gt;Store an idempotency receipt that points to that operation.&lt;/li&gt;
&lt;li&gt;Commit both together.&lt;/li&gt;
&lt;li&gt;Only then ask the external provider to send.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The durable record becomes the source of truth. The message announces a fact that already exists rather than promising that a later write will probably succeed.&lt;/p&gt;
&lt;p&gt;This does not make the database and email provider atomic. It gives the gap between them explicit semantics.&lt;/p&gt;
&lt;h3 id=&quot;make-replay-return-not-repeat&quot;&gt;Make replay return, not repeat&lt;/h3&gt;
&lt;p&gt;An idempotency key is useful only when it protects the irreversible side effect too.&lt;/p&gt;
&lt;p&gt;The receipt should bind a caller-generated command key to a stable fingerprint of the request. If the same key returns with the same request, the service reads and returns the original result. It does not send again. If the key returns with different input, the service rejects the conflict rather than guessing which request the caller intended.&lt;/p&gt;
&lt;p&gt;The database should also enforce uniqueness. Two identical requests can arrive concurrently before either sees the other's receipt. Let one commit win; have the loser load and return the winning result. Otherwise a perfectly designed application check can still race.&lt;/p&gt;
&lt;p&gt;The important invariant is not merely “one row.” It is “one durable result and one automatic send for one command.” Tests should assert both.&lt;/p&gt;
&lt;h3 id=&quot;unknown-is-not-the-same-as-failed&quot;&gt;Unknown is not the same as failed&lt;/h3&gt;
&lt;p&gt;Provider calls rarely produce only success or failure. They produce at least three useful outcomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Accepted: the provider acknowledged the message.&lt;/li&gt;
&lt;li&gt;Definitely rejected: the provider did not accept it.&lt;/li&gt;
&lt;li&gt;Unknown: the caller lost certainty, often through a timeout or interrupted response.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Unknown is the dangerous one. The message may already be queued. Reporting a definite failure can encourage an immediate retry and a second message.&lt;/p&gt;
&lt;p&gt;This is a small state-model decision with a large operational effect. Do not collapse “I do not know” into “it did not happen.” Preserve uncertainty so the recovery path can respond deliberately.&lt;/p&gt;
&lt;h3 id=&quot;keep-a-recovery-handle&quot;&gt;Keep a recovery handle&lt;/h3&gt;
&lt;p&gt;If delivery is definitely rejected after the database commit, deleting or closing the record may feel tidy. It also removes the user's only handle on the problem.&lt;/p&gt;
&lt;p&gt;Keeping the durable operation pending can be more honest. The UI can show that it exists, report that delivery failed, and offer an explicit resend. That resend is a new action, not a transparent replay of the original command.&lt;/p&gt;
&lt;p&gt;For a message carrying a secret, explicit resend should rotate the secret, invalidate the old link, persist the new state, and then send. Leaving both links active multiplies hidden authority. Reusing a raw secret is often impossible or unsafe if only its hash was stored.&lt;/p&gt;
&lt;p&gt;This recovery model needs expiry, status visibility, observability, and clear language. “Pending” must not imply “delivered.” The system should say what it knows and avoid claiming what it cannot know.&lt;/p&gt;
&lt;h3 id=&quot;test-the-gaps-not-only-the-happy-path&quot;&gt;Test the gaps, not only the happy path&lt;/h3&gt;
&lt;p&gt;The valuable tests live at the boundaries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Repeating the same command returns the same result, leaves one durable record, and sends once.&lt;/li&gt;
&lt;li&gt;Concurrent duplicates converge on one receipt and one result.&lt;/li&gt;
&lt;li&gt;A definite pre-provider rejection is reported while the recovery record remains available.&lt;/li&gt;
&lt;li&gt;An unknown outcome is not presented as definite non-delivery.&lt;/li&gt;
&lt;li&gt;Explicit resend invalidates the earlier secret before attempting delivery again.&lt;/li&gt;
&lt;li&gt;The provider is never called before the database commit succeeds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests express the reliability contract better than a broad “request succeeds” test. They also make later refactoring safer because the order and meaning of side effects are visible.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-honest-complexity&quot;&gt;The trade-off is honest complexity&lt;/h3&gt;
&lt;p&gt;This design is not free. It introduces command receipts, request fingerprints, provider outcome types, pending states, expiry, and explicit recovery. Operators need enough telemetry to distinguish rejected sends from unknown outcomes. Product language must explain uncertainty without alarming users.&lt;/p&gt;
&lt;p&gt;The alternative is simpler code with less truthful behaviour: phantom links, duplicate messages, or retries that silently create extra authority.&lt;/p&gt;
&lt;p&gt;My practical review question is: for every workflow that combines a database write with an external side effect, can we point to the commit point, replay rule, uncertainty model, and recovery action?&lt;/p&gt;
&lt;p&gt;If those four answers are explicit, retries become a designed part of the workflow rather than a hopeful repetition of it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28427/a-retry-is-not-safe-until-the-side-effect-is-safe</guid>
<pubDate>Thu, 24 Sep 2026 05:56:30 +0000</pubDate>
</item>
<item>
<title>When a Green Test Protects the Wrong Authorization Rule</title>
<link>https://coderlegion.com/28115/when-a-green-test-protects-the-wrong-authorization-rule</link>
<description>&lt;p&gt;An automated test is useful only when its assertion represents the rule we intend to preserve. That sounds obvious until a test passes for the exact condition that should make a reviewer uneasy.&lt;/p&gt;
&lt;p&gt;A recent Blazor change offered a concrete reminder. Two routed administrative pages required a signed-in user but did not assert a permission policy at the page. The links that led to them were filtered by permission. That made the interface look appropriately restricted, while a signed-in person could still enter the URL directly. Two existing tests expected the permissive page attributes, so the green suite helped the gap survive.&lt;/p&gt;
&lt;p&gt;The engineering lesson is broader than those pages: when a test blesses an authorization omission, fix the assertion and add an invariant that catches the next omission. Keep the invariant honest about what it can and cannot prove.&lt;/p&gt;
&lt;h3 id=&quot;navigation-visibility-is-a-different-boundary&quot;&gt;Navigation visibility is a different boundary&lt;/h3&gt;
&lt;p&gt;Consider an administrative interface with a configuration card. The card appears only when the current viewer has a configuration permission. The destination page, however, asks only whether the viewer is signed in. Hiding the card changes what the viewer sees; it does not change what the route accepts.&lt;/p&gt;
&lt;p&gt;The direct URL is the simplest counterexample. If the user knows or guesses it, the navigation filter is bypassed. A page-level policy is needed to decide whether that route may render for the viewer. The underlying API or command must make its own authorization decision as well. These checks serve different entry points, so one cannot stand in for the other.&lt;/p&gt;
&lt;p&gt;In the inspected change, the page policies were added. One data endpoint already had a stricter authorization rule and kept it. The lesson is to review each boundary on its own terms: visible link, routed page, and operation.&lt;/p&gt;
&lt;h3 id=&quot;read-assertions-as-product-decisions&quot;&gt;Read assertions as product decisions&lt;/h3&gt;
&lt;p&gt;The surprising part was not that a page attribute was too broad. It was that tests explicitly expected that breadth. One assertion accepted the absence of a policy; another looked for a bare sign-in attribute. Those statements had made an implementation detail into a contract.&lt;/p&gt;
&lt;p&gt;When correcting a test like this, start by naming the intended behaviour in plain language. For example: “This routed configuration page requires the configuration policy.” Then inspect the compiled authorization attribute and assert that policy. This makes the test follow the behaviour the framework will see, rather than a particular text fragment in a Razor file.&lt;/p&gt;
&lt;p&gt;That specific test matters even if a broader scan exists. A scan can report that a policy is present, but a policy for an unrelated administrative task could still pass it. The page-specific assertion checks the narrower decision about &lt;em&gt;which&lt;/em&gt; permission belongs there.&lt;/p&gt;
&lt;h3 id=&quot;turn-the-omission-into-an-invariant&quot;&gt;Turn the omission into an invariant&lt;/h3&gt;
&lt;p&gt;The change also added a structural test over routed administrative pages in two application areas. It checks whether each page asserts a permission policy. The test has a nonempty coverage guard, because a scan that accidentally finds zero pages is worse than a failing scan: it can look reassuring while doing nothing.&lt;/p&gt;
&lt;p&gt;This is a useful pattern for requirements that should apply across a family of files. A single page test protects a known case. A route scan makes it harder for a new page to repeat the same omission. Neither test needs to simulate every user and role to answer the structural question, “Did this route declare a permission policy?”&lt;/p&gt;
&lt;p&gt;The boundary of that answer is important. The scan does not prove that every declared policy is correct. It does not prove that an API rejects unauthorized calls. It does not prove that authorization is complete across the product. Those claims require different tests and review.&lt;/p&gt;
&lt;h3 id=&quot;make-exceptions-accountable&quot;&gt;Make exceptions accountable&lt;/h3&gt;
&lt;p&gt;Some landing pages can legitimately be available to any signed-in viewer because their contents filter themselves by the viewer's permissions. A blanket rule that rejects every such page would create noise and encourage people to weaken the test. The committed scan therefore allows explicit exemptions with reasons.&lt;/p&gt;
&lt;p&gt;An exemption is still a maintenance obligation. If a page is deleted, renamed, or later gains a policy, its old exemption should disappear. A second test checks for those stale entries. It keeps the exception list from becoming a historical catalogue that a future developer might mistake for permission to add more exceptions.&lt;/p&gt;
&lt;p&gt;The two corrected pages are pinned separately. That matters because someone could otherwise make the broad scan green by putting a vulnerable page on the exemption list. The pinned tests say those particular pages must keep their policies and cannot be exempted.&lt;/p&gt;
&lt;p&gt;The same change initially documented a separate unresolved permission decision as a known gap. A follow-up commit assigned that page a policy after an owner made the decision. Recording the gap first kept the review honest while the right policy was decided rather than guessed from a neighbouring page.&lt;/p&gt;
&lt;h3 id=&quot;the-practical-review-sequence&quot;&gt;The practical review sequence&lt;/h3&gt;
&lt;p&gt;When a green test has protected the wrong rule, I use this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reproduce the bypass at the relevant entry point in a controlled test or review, such as direct route access rather than clicking the navigation card.&lt;/li&gt;
&lt;li&gt;State the intended permission and correct the local assertion so it checks the framework-visible attribute.&lt;/li&gt;
&lt;li&gt;Add a structural invariant if the same omission can recur across many pages.&lt;/li&gt;
&lt;li&gt;Give every real exception a reason, and make stale exceptions fail.&lt;/li&gt;
&lt;li&gt;Keep page and operation authorization under separate review; presence of a page policy is only one piece of evidence.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This approach costs some test and exception-list maintenance. The benefit is a much earlier signal when another route omits a policy, while the specific assertions preserve the decisions that matter most. A green suite should mean that our intended boundary is holding, not merely that yesterday's implementation has stayed the same.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28115/when-a-green-test-protects-the-wrong-authorization-rule</guid>
<pubDate>Tue, 22 Sep 2026 07:08:12 +0000</pubDate>
</item>
<item>
<title>Offline Readiness Is a State-Machine Contract</title>
<link>https://coderlegion.com/27892/offline-readiness-is-a-state-machine-contract</link>
<description>&lt;p&gt;“Make it work offline” often turns into a storage task: serialize the last successful response, read it when the network fails, and move on.&lt;/p&gt;
&lt;p&gt;That is necessary, but it is not the hard part.&lt;/p&gt;
&lt;p&gt;The hard part is deciding what the stored response means across launch, failure, reconnect, configuration change, and session recovery. Once a mobile application depends on runtime configuration, offline readiness becomes a state-machine contract. A cache is only one implementation detail inside that contract.&lt;/p&gt;
&lt;h3 id=&quot;find-the-contradictory-assumptions-first&quot;&gt;Find the contradictory assumptions first&lt;/h3&gt;
&lt;p&gt;Consider a .NET MAUI wrapper hosting a Blazor UI. A lower startup layer may correctly treat a reachability probe as diagnostic: the network can be absent, so failure should not prevent launch. But a higher layer may immediately call a runtime-configuration endpoint and require a successful answer before activating the application scope.&lt;/p&gt;
&lt;p&gt;Each layer looks reasonable in isolation. Together they disagree.&lt;/p&gt;
&lt;p&gt;The lower layer says, “reachability is not a launch gate.” The higher layer says, “no live response, no ready state.” A short outage is then presented as invalid configuration even though nothing has been invalidated.&lt;/p&gt;
&lt;p&gt;This is why I like to draw the complete launch state machine before changing storage. List the states—uninitialised, resolving, ready from live data, ready from fallback data, unavailable—and name the events that move between them. Contradictions become much easier to see when they are transitions rather than scattered &lt;code&gt;if&lt;/code&gt; statements.&lt;/p&gt;
&lt;h3 id=&quot;classify-failure-before-consulting-the-cache&quot;&gt;Classify failure before consulting the cache&lt;/h3&gt;
&lt;p&gt;Not every failed request grants permission to use stale data.&lt;/p&gt;
&lt;p&gt;A timeout, lost connection, or temporary server error says, “the current answer is unavailable.” A recent cached configuration may be the best safe answer.&lt;/p&gt;
&lt;p&gt;An authoritative not-found or gone response says something different: “this configuration no longer exists here.” Serving the cache would override current server knowledge with old local knowledge. That is not resilience; it is refusal to accept invalidation.&lt;/p&gt;
&lt;p&gt;So the order matters:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Attempt the live load.&lt;/li&gt;
&lt;li&gt;Classify the failure.&lt;/li&gt;
&lt;li&gt;Consider fallback only for the transient class.&lt;/li&gt;
&lt;li&gt;Clear the fallback when an authoritative response makes it wrong.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This distinction is reusable well beyond configuration. Offline permissions, feature manifests, routing metadata, and other control-plane data all need an explicit invalidation rule.&lt;/p&gt;
&lt;h3 id=&quot;make-recent-enough-a-real-policy&quot;&gt;Make “recent enough” a real policy&lt;/h3&gt;
&lt;p&gt;Even a transient failure does not make every cached value safe.&lt;/p&gt;
&lt;p&gt;A useful policy checks at least four things. The entry must belong to the same logical scope. It must come from the same configured source. Its age must be within a deliberate bound. Its timestamp must not be implausibly far in the future.&lt;/p&gt;
&lt;p&gt;Source binding is easy to miss. Runtime configuration often contains addresses and capability switches. If an application is repointed but continues serving a cache captured from the old source, it may look healthy while sending work to the wrong place. Treating a source change as a cache miss is safer and easier to reason about.&lt;/p&gt;
&lt;p&gt;The age bound is a product and operational decision, not a magic constant. It should cover the outages the application is expected to tolerate while still placing a limit on how long retired configuration can survive. Document the reasoning so the next engineer knows whether changing it is a reliability decision or a cosmetic edit.&lt;/p&gt;
&lt;p&gt;Clock skew deserves a rule too. A future-dated entry can otherwise remain “fresh” for far longer than intended.&lt;/p&gt;
&lt;h3 id=&quot;preserve-continuity-when-connectivity-returns&quot;&gt;Preserve continuity when connectivity returns&lt;/h3&gt;
&lt;p&gt;An application that opens from fallback data is usable, but not settled. It should carry an explicit “needs refresh” hint.&lt;/p&gt;
&lt;p&gt;When connectivity returns, the tempting implementation is to rerun the entire startup pipeline. That can be surprisingly destructive. Full reinitialisation may advance a session epoch, cancel in-flight requests, clear actor state, restart background rails, and remount the renderer. On a flapping connection, users can pay that cost repeatedly.&lt;/p&gt;
&lt;p&gt;Prefer an in-place refresh while the current scope remains valid. Update the runtime configuration, clear the refresh hint, and let the active session continue. Escalate to full resolution only if the refresh proves that the scope disappeared or changed in a way that makes continuity unsafe.&lt;/p&gt;
&lt;p&gt;This gives reconnect two distinct transitions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;unavailable to online: resolve fully because there is no active scope to preserve;&lt;/li&gt;
&lt;li&gt;ready-from-fallback to online: refresh in place because there is useful state to protect.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those transitions may share a network event, but they should not share an indiscriminate reset.&lt;/p&gt;
&lt;h3 id=&quot;let-storage-fail-softly-and-test-the-wiring&quot;&gt;Let storage fail softly—and test the wiring&lt;/h3&gt;
&lt;p&gt;Device storage is another dependency, not a certainty. Reads can fail, writes can fail, and cleanup can fail. If a cache exists to make cold start more resilient, an escaping storage exception must not become a new cold-start crash.&lt;/p&gt;
&lt;p&gt;Fail-soft behaviour should be intentional: a failed read behaves like a miss, a failed write loses future offline convenience, and a failed clear remains bounded by the expiry policy. Cancellation is different and should still propagate.&lt;/p&gt;
&lt;p&gt;Test the serializer separately with round trips. A cache that writes successfully but cannot read its own format is indistinguishable from having no fallback at all.&lt;/p&gt;
&lt;p&gt;Also test dependency-injection resolution through the real container. Optional constructor dependencies are convenient, but they can compile and quietly fall back to a null implementation when registration is missing. A focused integration test proves that the platform adapter actually reaches the service.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-explicit-complexity-1&quot;&gt;The trade-off is explicit complexity&lt;/h3&gt;
&lt;p&gt;This design is more complex than “try the network, then read a file.” It introduces failure classification, cache provenance, expiry, clock rules, lifecycle state, reconnect transitions, serialization, and a wider test matrix.&lt;/p&gt;
&lt;p&gt;That complexity is buying something concrete: continuity without pretending stale configuration is always correct.&lt;/p&gt;
&lt;p&gt;The practical review question is not, “Do we have a cache?” Ask instead:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which state are we in, what evidence permits this fallback, and what is the least disruptive path back to live truth?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If the implementation can answer that clearly, offline readiness has become a deliberate contract rather than an accidental side effect.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27892/offline-readiness-is-a-state-machine-contract</guid>
<pubDate>Mon, 21 Sep 2026 06:07:42 +0000</pubDate>
</item>
<item>
<title>Pagination Is a Consistency Contract</title>
<link>https://coderlegion.com/27813/pagination-is-a-consistency-contract</link>
<description>&lt;p&gt;Page numbers feel harmless. They are familiar, easy to render, and easy to explain. But on a mutable work queue, they can lie.&lt;/p&gt;
&lt;p&gt;Imagine a reviewer reading a newest-first queue. They load the first 25 items. Before they request the next page, a new item arrives at the top. An offset such as &lt;code&gt;skip 25&lt;/code&gt; now starts one row later than it would have a moment ago. The last row from page one may appear again, and the row displaced by the insert may never appear in that walk.&lt;/p&gt;
&lt;p&gt;Nothing crashed. No error was logged. The interface simply presented an inconsistent view of the work.&lt;/p&gt;
&lt;p&gt;The engineering lesson is that pagination over changing data is a consistency contract, not a decorative control beneath a table.&lt;/p&gt;
&lt;h3 id=&quot;begin-with-the-mutation-model&quot;&gt;Begin with the mutation model&lt;/h3&gt;
&lt;p&gt;The right pagination strategy depends on how the collection changes.&lt;/p&gt;
&lt;p&gt;For an append-mostly queue ordered newest first, new work normally arrives above the operator's current position. Other fields, such as workflow status, may change while the operator is reviewing an item. Those two facts suggest a useful boundary: continue from something fixed when the item entered the queue, not from a mutable field and not from the current numeric position.&lt;/p&gt;
&lt;p&gt;A submission timestamp can provide that boundary if it is frozen when the item is created. Status usually cannot; ordering by status lets a decision move rows between pages while someone is walking the list.&lt;/p&gt;
&lt;p&gt;The principle is broader than timestamps: choose an ordering key whose meaning remains stable for the duration of a walk.&lt;/p&gt;
&lt;h3 id=&quot;make-the-server-own-the-continuation&quot;&gt;Make the server own the continuation&lt;/h3&gt;
&lt;p&gt;A client should not reconstruct position from a page number, a visible row, or a guessed offset. The server should return an opaque continuation and accept it unchanged on the next request.&lt;/p&gt;
&lt;p&gt;For a newest-first queue, a continuation can describe the last timestamp boundary and how many rows at that exact instant have already been emitted. The next query includes rows at or below the boundary, skips only the already-seen ties at that boundary, and takes one extra row to determine whether another page exists.&lt;/p&gt;
&lt;p&gt;Why count ties instead of comparing an identifier? Identifier ordering is not always portable across database providers. Let the query provider apply its established ordering, and record only how far through the boundary group the walk has progressed.&lt;/p&gt;
&lt;p&gt;Opaque does not mean unchecked. Reject blank, malformed, oversized, or unsupported continuations. A bad continuation should not silently restart at the top, because a restart looks like valid data with unexplained duplicates.&lt;/p&gt;
&lt;h3 id=&quot;bind-position-to-query-context&quot;&gt;Bind position to query context&lt;/h3&gt;
&lt;p&gt;A continuation names a position in one specific ordered result set. Change the filter and that position may no longer mean anything.&lt;/p&gt;
&lt;p&gt;Suppose page one was filtered to one workflow cycle, then the same continuation was replayed with the filter removed. Honouring it could skip rows that were never part of the original walk. The continuation should therefore carry enough context to prove it belongs to the active query. If the context differs, refuse the request and let the client restart explicitly.&lt;/p&gt;
&lt;p&gt;The same discipline applies to page size. Put a small upper bound in the contract and reject values outside it. Quietly shrinking an oversized request hides caller mistakes; accepting it defeats the point of paging.&lt;/p&gt;
&lt;p&gt;Bounding the first query is only half the work. If each row needs evidence, comments, or another related record, fetch those details only for identifiers on the current page. Otherwise an apparently paged endpoint can still perform an unbounded secondary read.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-operator-s-place&quot;&gt;Preserve the operator's place&lt;/h3&gt;
&lt;p&gt;Continuation cursors naturally move forward. A usable review screen also needs Back and refresh.&lt;/p&gt;
&lt;p&gt;The client can keep a short trail of the server-provided continuations used to open each visited page. Previous then replays the cursor that originally opened the prior page. The client is not interpreting the cursor; it is remembering the path.&lt;/p&gt;
&lt;p&gt;After a reviewer changes an item, refresh the page they are currently viewing. Returning to page one after every decision is technically simple and operationally punishing. It also encourages people to work around the interface rather than trust it.&lt;/p&gt;
&lt;p&gt;There is one awkward edge case: a later page may become empty before it is refreshed. Do not render a global empty-state message that implies the entire queue has vanished. Reset to the first page and show the current truth from a valid starting position.&lt;/p&gt;
&lt;h3 id=&quot;test-the-walk-not-only-the-query&quot;&gt;Test the walk, not only the query&lt;/h3&gt;
&lt;p&gt;Happy-path tests that assert “25 rows returned” are not enough. The important properties live between requests.&lt;/p&gt;
&lt;p&gt;Useful executable checks include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a new item arriving above the boundary neither duplicates nor hides an existing row;&lt;/li&gt;
&lt;li&gt;a workflow status change does not alter page membership;&lt;/li&gt;
&lt;li&gt;several items sharing one timestamp are neither repeated nor lost;&lt;/li&gt;
&lt;li&gt;a continuation cannot be replayed with a different filter;&lt;/li&gt;
&lt;li&gt;malformed continuations and invalid page sizes are refused;&lt;/li&gt;
&lt;li&gt;forward and backward navigation use the exact server continuations;&lt;/li&gt;
&lt;li&gt;a write refreshes the active page; and&lt;/li&gt;
&lt;li&gt;an empty later page recovers without claiming the whole queue is empty.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests describe the contract more clearly than the word “paged” ever could.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-explicit-complexity-2&quot;&gt;The trade-off is explicit complexity&lt;/h3&gt;
&lt;p&gt;Cursor paging asks more of both sides. The server must define and validate a stable continuation. The client must retain a small history. Ties and disappearing pages need deliberate behaviour. Troubleshooting is less intuitive than looking at &lt;code&gt;page=3&lt;/code&gt; in a URL.&lt;/p&gt;
&lt;p&gt;In return, database work stays bounded and a reviewer can walk a changing queue without silent duplicates or gaps. That is a worthwhile trade whenever missing one item matters more than keeping the paging code superficially simple.&lt;/p&gt;
&lt;p&gt;Before choosing offsets for a mutable list, simulate one insert and one update between consecutive requests. If the same walk no longer produces the same set of rows, the pager needs a stronger contract.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27813/pagination-is-a-consistency-contract</guid>
<pubDate>Sun, 20 Sep 2026 07:52:36 +0000</pubDate>
</item>
<item>
<title>When EF Core Sees Only Part of Your Model</title>
<link>https://coderlegion.com/27740/when-ef-core-sees-only-part-of-your-model</link>
<description>&lt;p&gt;Framework warnings are valuable because they turn hidden risk into visible friction. But a warning is still an interpretation of the evidence available to the tool. In a modular application, the command-line process may not load the same assemblies as the running application. When that happens, the design-time model can be a projection rather than the whole truth.&lt;/p&gt;
&lt;p&gt;That creates an uncomfortable situation: a migration warning may be both technically correct about the model it sees and dangerously wrong about the database operation it proposes.&lt;/p&gt;
&lt;p&gt;The answer is not to ignore warnings casually. It is to identify the boundary, narrow the exception, and replace the lost signal with a stronger executable invariant.&lt;/p&gt;
&lt;h3 id=&quot;the-model-can-change-with-the-process&quot;&gt;The model can change with the process&lt;/h3&gt;
&lt;p&gt;Consider a lower-level persistence module that owns a database context. At runtime, higher-level modules can contribute entity configurations through a discovery mechanism. This preserves the dependency direction: the lower-level module does not need references back to every feature that extends it.&lt;/p&gt;
&lt;p&gt;Now run command-line migration tooling from the lower-level project. The higher-level contributor may not be loaded, because adding that reference would create a project cycle. The tool builds a valid model, but it is only the portion visible from that process.&lt;/p&gt;
&lt;p&gt;The migration snapshot, meanwhile, includes the contributed table. It was created from the full application model and must remain there for every host that uses the shared schema.&lt;/p&gt;
&lt;p&gt;The differ compares those two views and concludes that the table exists in the snapshot but not in the current model. Its proposed fix is logical from that narrow perspective: drop the table.&lt;/p&gt;
&lt;p&gt;From the system’s perspective, that operation is destructive.&lt;/p&gt;
&lt;h3 id=&quot;do-not-fix-the-snapshot-to-match-a-blind-spot&quot;&gt;Do not “fix” the snapshot to match a blind spot&lt;/h3&gt;
&lt;p&gt;Regenerating the snapshot until the warning disappears is tempting. It is also exactly the wrong response when the design-time model is incomplete by construction.&lt;/p&gt;
&lt;p&gt;The first diagnostic question should be: what is the entire proposed difference? A probe migration can make the answer concrete. If the only change is removal of a table supplied by a module that the tooling process cannot load, the warning describes a visibility gap—not necessarily real drift.&lt;/p&gt;
&lt;p&gt;This distinction matters. “The warning is a false positive” is too broad. A particular warning, for a particular context, under a documented loading boundary may be a false positive. Other differences could still be real.&lt;/p&gt;
&lt;p&gt;The safe decision is therefore narrow: allow the legitimate database-update path to proceed despite that one known condition, while keeping a separate full-model comparison as the authoritative drift test.&lt;/p&gt;
&lt;h3 id=&quot;suppression-spends-a-safety-signal&quot;&gt;Suppression spends a safety signal&lt;/h3&gt;
&lt;p&gt;Ignoring a warning removes friction. It does not remove the underlying ambiguity.&lt;/p&gt;
&lt;p&gt;If a design-time factory suppresses a known pending-model warning, the team has spent a safety signal. That signal should be replaced with evidence closer to the failure mode.&lt;/p&gt;
&lt;p&gt;In this case, the feared outcome is not an abstract “model mismatch.” It is a migration that drops, renames, or damages a table the design-time process cannot see. That can be tested directly.&lt;/p&gt;
&lt;p&gt;An executable migration guard can inspect the operations produced by every committed migration and fail when it finds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a drop of the protected table;&lt;/li&gt;
&lt;li&gt;a rename that moves it out of the contract;&lt;/li&gt;
&lt;li&gt;a column removal targeting that table; or&lt;/li&gt;
&lt;li&gt;raw SQL containing the destructive operation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is more specific than the original warning, and that specificity is a strength. The test says exactly which invariant must survive.&lt;/p&gt;
&lt;h3 id=&quot;negative-guards-need-positive-controls&quot;&gt;Negative guards need positive controls&lt;/h3&gt;
&lt;p&gt;There is a subtle testing trap here. A test that scans migrations and finds no forbidden operation can pass because the migrations are safe—or because discovery found nothing.&lt;/p&gt;
&lt;p&gt;That is a vacuous pass.&lt;/p&gt;
&lt;p&gt;Add a positive control that proves the scan sees the migration that originally created the protected table. Now the pair of tests establishes both halves of the claim:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the evidence set is present; and&lt;/li&gt;
&lt;li&gt;none of that evidence violates the destructive-operation invariant.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This pattern generalizes well. Whenever a test says “nothing bad exists,” pair it with proof that the search space is populated. It is useful for dependency scans, authorization maps, route inventories, migration checks, and reflection-based conventions.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-full-model-as-the-authority&quot;&gt;Keep the full model as the authority&lt;/h3&gt;
&lt;p&gt;The migration guard protects one known destructive edge. It does not prove that every legitimate model change has a migration.&lt;/p&gt;
&lt;p&gt;For that, retain a snapshot comparison that runs in a process where all runtime contributors are loaded. That test compares the snapshot with the full model rather than the design-time projection.&lt;/p&gt;
&lt;p&gt;These checks have different jobs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the full-model comparison detects real snapshot drift;&lt;/li&gt;
&lt;li&gt;the migration guard blocks the known destructive artifact;&lt;/li&gt;
&lt;li&gt;the positive control proves the guard actually inspected meaningful input; and&lt;/li&gt;
&lt;li&gt;the narrowly scoped suppression keeps the operational update path usable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, they are stronger than treating one framework warning as universally authoritative.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-deliberate-maintenance&quot;&gt;The trade-off is deliberate maintenance&lt;/h3&gt;
&lt;p&gt;This approach is not free. The guard knows about a specific architectural boundary. If extension mechanics change, the tests and documentation must change with them. Contributors need to understand why the suppression exists and why generated migration output still requires review.&lt;/p&gt;
&lt;p&gt;The alternative is worse: either block legitimate operations forever, or normalize a habit of dismissing warnings and accepting generated drops.&lt;/p&gt;
&lt;p&gt;The practical review rule is simple: when tooling sees only a projection, document the blind spot, scope the exception, and encode the real safety property as a failing test. Framework diagnostics are excellent inputs. Your architecture still owns the final claim.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27740/when-ef-core-sees-only-part-of-your-model</guid>
<pubDate>Sat, 19 Sep 2026 12:18:35 +0000</pubDate>
</item>
<item>
<title>A Successful Security Call May Prove Nothing</title>
<link>https://coderlegion.com/27739/a-successful-security-call-may-prove-nothing</link>
<description>&lt;p&gt;Security integrations often fail in a surprisingly polite way. The library loads. Initialization succeeds. The function returns. Nothing crashes. Yet the protection we expected may not have participated at all.&lt;/p&gt;
&lt;p&gt;That distinction matters when an application accepts untrusted documents that another person may later open. The relevant question is not “did the scanning API answer?” It is “did a real security engine inspect this content and produce a trustworthy verdict?”&lt;/p&gt;
&lt;p&gt;A recent engineering change reinforced a pattern worth carrying into other systems: prove the capability end to end, fail closed when the proof is missing, and preserve the difference between malicious content and content that has not been judged.&lt;/p&gt;
&lt;h3 id=&quot;integration-health-is-not-capability-health&quot;&gt;Integration health is not capability health&lt;/h3&gt;
&lt;p&gt;Suppose an operating-system security interface sits between your application and whichever antimalware product is installed. It is tempting to treat successful initialization as a health check. A clean transport result feels even stronger: the call returned normally and reported nothing detected.&lt;/p&gt;
&lt;p&gt;But an integration layer and its downstream provider are separate things. The interface can be present while the engine behind it is stopped, unavailable, or incorrectly registered. In that condition, a nominal response may demonstrate only that the front door opened.&lt;/p&gt;
&lt;p&gt;This is a general reliability problem, not an accusation against one API. Message brokers can accept a connection while a critical consumer is stalled. A secrets client can authenticate while the expected key is unavailable. A feature-flag SDK can initialize while serving stale defaults. Health must be defined by the capability the application depends on, not by the first component in the chain.&lt;/p&gt;
&lt;p&gt;For a document scanner, “the library loaded” is weak evidence. “A known signal travelled through the production scanning path and produced the expected security verdict” is much stronger.&lt;/p&gt;
&lt;h3 id=&quot;test-the-behaviour-you-actually-depend-on&quot;&gt;Test the behaviour you actually depend on&lt;/h3&gt;
&lt;p&gt;The design used a harmless standard test signal that security engines are expected to recognize. The literal signal does not belong in application content or public examples; what matters is its role.&lt;/p&gt;
&lt;p&gt;At startup, the application sends that signal through the same in-memory scanning boundary used for uploaded bytes. The feature is registered only if the expected detection comes back. On unsupported hosts, or when no live engine answers, the risky upload capability remains unavailable.&lt;/p&gt;
&lt;p&gt;That is a capability probe. It asks the downstream system to demonstrate the exact category of behaviour the application needs before the application trusts it.&lt;/p&gt;
&lt;p&gt;The same proof is repeated when a scan begins. Startup checks become stale. A service can stop, a provider can be disabled, or policy can change while the host remains running. Rechecking at the point of use makes failure visible before untrusted content is accepted as safe.&lt;/p&gt;
&lt;p&gt;This does add work. A liveness probe consumes a small amount of time, and the application may temporarily deny uploads during a dependency outage. That cost is deliberate. Availability is being traded for an honest security boundary rather than for a comforting but unsupported status flag.&lt;/p&gt;
&lt;h3 id=&quot;-malicious-and-not-judged-are-different-states&quot;&gt;“Malicious” and “not judged” are different states&lt;/h3&gt;
&lt;p&gt;Failing closed does not mean treating every failure as malware.&lt;/p&gt;
&lt;p&gt;A positive engine verdict can justify refusing a document. An administrator policy may also explicitly block it. Those are security decisions based on evidence.&lt;/p&gt;
&lt;p&gt;An exception is different. The bytes may be missing. The scanning interface may fail to initialize. The engine may disappear between startup and use. None of those facts proves that the document is malicious. They prove that the system does not have a verdict.&lt;/p&gt;
&lt;p&gt;A useful state model keeps at least three outcomes separate:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Cleared: the control produced an acceptable verdict.&lt;/li&gt;
&lt;li&gt;Refused: the control positively detected or blocked the content.&lt;/li&gt;
&lt;li&gt;Pending: the system could not obtain a trustworthy verdict.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Collapsing the third state into “cleared” silently weakens security. Collapsing it into “refused” creates false certainty and can turn a temporary infrastructure fault into a permanent user-facing decision. Leaving the item pending supports a retry, an operational alert, or a safe recovery path while remaining truthful about what happened.&lt;/p&gt;
&lt;h3 id=&quot;keep-adjacent-boundaries-aligned&quot;&gt;Keep adjacent boundaries aligned&lt;/h3&gt;
&lt;p&gt;The scan is only one part of the upload boundary. The storage layer still owns how opaque keys resolve to files, including protection against paths escaping the intended root. The scanner should use that existing resolver rather than copying path logic into a second component.&lt;/p&gt;
&lt;p&gt;That avoids a subtle split-brain problem: storage accepts one interpretation of a key while scanning uses another. Security checks are strongest when every stage inspects the same bytes through the same trusted boundary.&lt;/p&gt;
&lt;p&gt;Tests should reflect those boundaries too. Pure verdict mapping can be tested with ordinary inputs, but the liveness question deserves at least one test against the real host integration where the platform permits it. A mock can prove that our code handles the answer we programmed the mock to return. It cannot prove that the downstream engine is actually answering.&lt;/p&gt;
&lt;p&gt;Useful tests include clean content, blocked-result ranges, an unavailable provider, missing bytes, and traversal attempts. Together they exercise the difference between a positive verdict, an operational failure, and an invalid storage request.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-6&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a security or reliability integration, I now ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What observable behaviour proves the downstream capability is alive?&lt;/li&gt;
&lt;li&gt;Are we checking a transport, a library, or the outcome we actually need?&lt;/li&gt;
&lt;li&gt;Can the dependency fail after startup, and is it checked again at use time?&lt;/li&gt;
&lt;li&gt;Does failure disable the risky capability or quietly weaken it?&lt;/li&gt;
&lt;li&gt;Are positive security verdicts kept distinct from operational errors?&lt;/li&gt;
&lt;li&gt;Does the test suite cross the real boundary that a mock would hide?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The broader lesson is simple: successful plumbing is not the same as successful protection. Trust the capability only after it demonstrates the behaviour your application depends on—and keep uncertainty visible when it cannot.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27739/a-successful-security-call-may-prove-nothing</guid>
<pubDate>Sat, 19 Sep 2026 11:45:44 +0000</pubDate>
</item>
<item>
<title>Your Reader Cannot Be Stricter Than Your Schema</title>
<link>https://coderlegion.com/27483/your-reader-cannot-be-stricter-than-your-schema</link>
<description>&lt;p&gt;&lt;code&gt;SingleOrDefault&lt;/code&gt; looks like a small query choice. In practice, it is an assertion about your data model: for this predicate, zero or one row may exist.&lt;/p&gt;
&lt;p&gt;That assertion is valuable when storage enforces the same rule. When the database permits duplicates, however, the reader is relying on an invariant that does not exist. One unexpected row can turn an ordinary lookup into an exception before validation, authorization, or other normal handling begins.&lt;/p&gt;
&lt;p&gt;I recently reviewed a committed recovery for exactly that class of mismatch. The useful lesson was not the specific lookup. It was how to contain the exception without pretending the data had become correct.&lt;/p&gt;
&lt;h3 id=&quot;a-query-can-encode-a-hidden-contract&quot;&gt;A query can encode a hidden contract&lt;/h3&gt;
&lt;p&gt;Consider a generalized account table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Rows permitted by storage: 0..many per normalized key
Rows expected by reader:    0..1 per normalized key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The read path might be perfectly reasonable in isolation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var record = await records.SingleOrDefaultAsync(x =&amp;gt; x.LookupKey == key);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the method name does not create uniqueness. It only checks cardinality at read time. If two rows match, the query throws.&lt;/p&gt;
&lt;p&gt;This is why I now treat &lt;code&gt;Single&lt;/code&gt; and &lt;code&gt;SingleOrDefault&lt;/code&gt; as executable invariant assertions. During review, I ask: what proves the invariant? A unique index? A constrained aggregate? A transactionally protected write path? Or only an earlier existence check that can race?&lt;/p&gt;
&lt;h3 id=&quot;containment-is-not-repair&quot;&gt;Containment is not repair&lt;/h3&gt;
&lt;p&gt;When duplicate data already exists, immediately adding a unique index may be unsafe. Migration can fail on the existing rows. Previously accepted imports or registrations may begin failing without a useful remediation path.&lt;/p&gt;
&lt;p&gt;At the same time, leaving a critical read path to throw can make a narrow data defect disproportionately disruptive.&lt;/p&gt;
&lt;p&gt;A staged response separates two goals:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Containment:&lt;/strong&gt; provide a deterministic, non-crashing resolution path while preserving security and making the defect visible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Correction:&lt;/strong&gt; repair the existing data and enforce the intended rule for future writes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The distinction matters. A tolerant reader is a recovery mechanism, not evidence that duplicates are acceptable.&lt;/p&gt;
&lt;h3 id=&quot;properties-of-a-safe-recovery-path&quot;&gt;Properties of a safe recovery path&lt;/h3&gt;
&lt;p&gt;A useful containment pattern keeps the normal case boring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;try
{
    return await ReadExpectedSingleAsync(key);
}
catch (InvalidOperationException)
{
    var matches = await ReadBoundedCandidatesAsync(key);
    if (matches.Count &amp;lt; 2)
        throw;

    ReportCollision(matches.Select(x =&amp;gt; x.OpaqueId));
    return ChooseDeterministically(matches);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sketch is intentionally incomplete, but it highlights several important properties.&lt;/p&gt;
&lt;h4 id=&quot;keep-the-fast-path-unchanged&quot;&gt;Keep the fast path unchanged&lt;/h4&gt;
&lt;p&gt;Most lookups are not ambiguous. Do not make every request pay for a corruption-recovery query. Invoke the fallback only after the expected lookup demonstrates the precise failure you are prepared to handle.&lt;/p&gt;
&lt;h4 id=&quot;prove-ambiguity-before-handling-it&quot;&gt;Prove ambiguity before handling it&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;InvalidOperationException&lt;/code&gt; can come from many places. Catching it and returning “not found” would turn an unrelated store failure into a silent wrong answer. Re-query narrowly; if there are not actually multiple matches, rethrow the original fault.&lt;/p&gt;
&lt;h4 id=&quot;choose-deterministically&quot;&gt;Choose deterministically&lt;/h4&gt;
&lt;p&gt;Never accept database enumeration order as a resolution policy. Define explicit ranking from legitimate domain facts, then add a stable final tie-break. The same data must produce the same candidate on every attempt.&lt;/p&gt;
&lt;p&gt;Determinism does not make the selected record correct. It prevents the recovery itself from becoming random.&lt;/p&gt;
&lt;h4 id=&quot;preserve-downstream-verification&quot;&gt;Preserve downstream verification&lt;/h4&gt;
&lt;p&gt;Resolving one candidate must not grant access, approve a transaction, or bypass an eligibility rule. Credential checks, authorization, state validation, and other security decisions still run after resolution. Containment should make the lookup deterministic, not weaken the boundaries that follow it. A legitimate candidate may still lose the ranking, so some affected operations can remain unavailable until the data is repaired.&lt;/p&gt;
&lt;h4 id=&quot;bound-and-observe-the-fallback&quot;&gt;Bound and observe the fallback&lt;/h4&gt;
&lt;p&gt;A pathological key should not cause an unbounded scan during a critical request. Cap diagnostic reads, raise a high-signal operational event, and log only what responders need. Opaque record identifiers may support repair; the sensitive lookup value often does not belong in logs.&lt;/p&gt;
&lt;h3 id=&quot;the-stronger-end-state-lives-in-storage&quot;&gt;The stronger end state lives in storage&lt;/h3&gt;
&lt;p&gt;After containment, the work is not finished. The durable sequence is usually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;inventory collisions
-&amp;gt; decide merge or deletion policy
-&amp;gt; repair existing rows
-&amp;gt; reject or reconcile conflicting writes
-&amp;gt; add the storage constraint
-&amp;gt; remove or narrow temporary recovery code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For relational data, that often means a unique index aligned with the normalization and null semantics used by the application. The write path should return a useful domain result when the constraint rejects a conflict. A pre-insert lookup can improve the message, but the database remains the concurrency-safe authority.&lt;/p&gt;
&lt;p&gt;There is a real trade-off. Immediate enforcement gives the cleanest future guarantee but can break deployment against historical data. Temporary tolerance contains the exception but adds policy, cannot guarantee later verification will succeed, and can become permanent by neglect. Treat it as migration work with an owner, telemetry, and an exit condition.&lt;/p&gt;
&lt;h3 id=&quot;test-the-invariant-and-the-escape-hatch&quot;&gt;Test the invariant and the escape hatch&lt;/h3&gt;
&lt;p&gt;Focused tests should cover more than “duplicates do not throw.” I would pin:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the ordinary single-row path remains untouched;&lt;/li&gt;
&lt;li&gt;selection is stable when candidates arrive in different orders;&lt;/li&gt;
&lt;li&gt;inactive or retired records cannot outrank valid ones;&lt;/li&gt;
&lt;li&gt;unrelated exceptions escape unchanged;&lt;/li&gt;
&lt;li&gt;downstream verification still decides the outcome;&lt;/li&gt;
&lt;li&gt;diagnostics contain repairable identifiers but no sensitive key;&lt;/li&gt;
&lt;li&gt;the candidate read is bounded;&lt;/li&gt;
&lt;li&gt;the future uniqueness constraint rejects a conflicting concurrent write.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The deeper lesson is architectural: reads, writes, and storage must agree on cardinality. If a reader says “exactly one,” make that promise explicit at the boundary able to enforce it. Until then, the code is not reading an invariant. It is discovering, at runtime, whether the hope was true.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27483/your-reader-cannot-be-stricter-than-your-schema</guid>
<pubDate>Thu, 17 Sep 2026 08:20:48 +0000</pubDate>
</item>
<item>
<title>Realtime Is a Hint, Not Your Source of Truth</title>
<link>https://coderlegion.com/27482/realtime-is-a-hint-not-your-source-of-truth</link>
<description>&lt;p&gt;A notification badge looks like a small feature. In a distributed application, it can expose a large architectural assumption.&lt;/p&gt;
&lt;p&gt;I recently investigated a badge that sometimes stayed stale in an ASP.NET Core and Blazor application running across multiple server instances. The notification itself had been created successfully. Durable state was correct. A separate push path could also complete. Yet the visible count did not always change until the user left the screen and returned.&lt;/p&gt;
&lt;p&gt;The bug was not really about arithmetic. It was about assigning too much authority to a realtime transport.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-assumption&quot;&gt;The hidden assumption&lt;/h3&gt;
&lt;p&gt;The UI initially loaded its count from the server and then relied on live events to remain current. That feels reasonable while developing against one server with a stable connection. In production, several ordinary conditions weaken the assumption:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The client can disconnect briefly.&lt;/li&gt;
&lt;li&gt;A load balancer can place the sender and receiver on different instances.&lt;/li&gt;
&lt;li&gt;A workflow can create the same durable state without passing through the page that happens to broadcast.&lt;/li&gt;
&lt;li&gt;A transport or hub can fail after the database operation has already succeeded.&lt;/li&gt;
&lt;li&gt;An idempotent retry can revisit work that should not increment the UI again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these conditions makes realtime technology a poor choice. They simply mean that a realtime message should usually be treated as a hint that fresh state is available, not as the state itself.&lt;/p&gt;
&lt;h3 id=&quot;a-three-layer-reliability-pattern&quot;&gt;A three-layer reliability pattern&lt;/h3&gt;
&lt;p&gt;The repair became much clearer once the responsibilities were separated into three layers.&lt;/p&gt;
&lt;h4 id=&quot;first-persist-the-truth&quot;&gt;First, persist the truth&lt;/h4&gt;
&lt;p&gt;The durable record must be committed independently of whether any connected client is listening. If the user closes the app, changes networks, or connects to another instance, the correct unread state must still be recoverable later.&lt;/p&gt;
&lt;p&gt;This ordering matters. Broadcasting first can produce a convincing UI update for data that subsequently fails to commit. Persisting first means a missed broadcast can be healed; an invented broadcast cannot be trusted.&lt;/p&gt;
&lt;h4 id=&quot;second-broadcast-from-the-business-chokepoint&quot;&gt;Second, broadcast from the business chokepoint&lt;/h4&gt;
&lt;p&gt;The live update originally depended on a narrower UI workflow. Other creation paths could produce the same durable result without emitting the event. Moving the broadcast to the shared dispatch path made the behavior consistent across interactive and background work.&lt;/p&gt;
&lt;p&gt;The broadcast was also tied to the outcome of the durable write. A genuinely new record emits a hint. An idempotent replay that finds the work already completed does not. That prevents duplicate increments and makes the event reflect a real state transition rather than merely an attempted command.&lt;/p&gt;
&lt;h4 id=&quot;third-reconcile-periodically&quot;&gt;Third, reconcile periodically&lt;/h4&gt;
&lt;p&gt;Even a correctly placed broadcast can be missed. The client therefore refreshes the authoritative count on a bounded interval. The loop is cancelled when the component is disposed, and a transient refresh failure does not kill future attempts.&lt;/p&gt;
&lt;p&gt;This is not polling as the primary experience. Realtime still gives the fast path. Polling is the repair path.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-1&quot;&gt;The trade-off&lt;/h3&gt;
&lt;p&gt;Hybrid delivery is not free.&lt;/p&gt;
&lt;p&gt;Periodic reconciliation adds read traffic. The interval also defines a maximum period of visible staleness when a realtime event is missed. A very short interval increases load and can waste battery or bandwidth on mobile clients. A long interval lowers cost but leaves stale UI visible for longer.&lt;/p&gt;
&lt;p&gt;That makes the interval a product and operational decision, not a magic constant. Ask how damaging stale information is, how expensive the read is, how many active clients exist, and whether the endpoint can use a cheap aggregate or cache.&lt;/p&gt;
&lt;p&gt;For a badge, bounded staleness may be acceptable. For a payment confirmation or permission change, the interaction may require an immediate authoritative read instead. The pattern stays the same, but the recovery budget changes.&lt;/p&gt;
&lt;p&gt;Failure boundaries matter too. Once durable work has succeeded, a hub failure should not turn the whole operation into a failure. Otherwise a temporary transport problem can encourage retries of already-completed business work. Log the broadcast failure, observe it, and let reconciliation repair the presentation.&lt;/p&gt;
&lt;h3 id=&quot;test-the-contract-not-the-transport&quot;&gt;Test the contract, not the transport&lt;/h3&gt;
&lt;p&gt;The most useful tests were not attempts to prove that a network never fails. They pinned the business boundary:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New durable state produces one realtime hint.&lt;/li&gt;
&lt;li&gt;Previously completed or deduplicated work produces no new hint.&lt;/li&gt;
&lt;li&gt;A failing realtime transport does not invalidate successful durable work.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Client-side tests can add the complementary guarantees: an initial authoritative load occurs, the reconciliation loop refreshes state, transient errors do not stop later ticks, and disposal cancels the loop.&lt;/p&gt;
&lt;p&gt;These tests document the intended hierarchy. The database-backed read is authoritative. Realtime reduces latency. Reconciliation restores convergence.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-7&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a live-update feature, I now ask five questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What is the source of truth?&lt;/li&gt;
&lt;li&gt;Where is the single shared point that knows a real state transition occurred?&lt;/li&gt;
&lt;li&gt;Can the live event be safely missed or duplicated?&lt;/li&gt;
&lt;li&gt;How and how quickly does the client converge again?&lt;/li&gt;
&lt;li&gt;What happens if the transport fails after the business operation succeeds?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If those answers are vague, the feature may look realtime in a demo while remaining fragile in production.&lt;/p&gt;
&lt;p&gt;The broader lesson is modest: reliability often comes from combining mechanisms with explicit roles. Durable storage provides truth. Realtime provides speed. Periodic reconciliation provides healing. None needs to pretend it can do the others’ job.&lt;/p&gt;
&lt;p&gt;That separation made a small badge easier to trust, and it is a pattern I would reuse for dashboards, counters, presence indicators, job progress, and other derived UI state.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27482/realtime-is-a-hint-not-your-source-of-truth</guid>
<pubDate>Thu, 17 Sep 2026 08:20:15 +0000</pubDate>
</item>
<item>
<title>Twinify 1.1.0: Map Without Profiles, Hide Secrets With Attributes, and Ship Native AOT</title>
<link>https://coderlegion.com/27216/twinify-1-1-0-map-without-profiles-hide-secrets-with-attributes-and-ship-native-aot</link>
<description>&lt;p&gt;If you missed the earlier posts on Twinify: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/stevenkamwaza/getting-started-with-twinify-a-free-open-source-object-mapper-for-modern-net-2hn3&quot;&gt;Twinify is  a MIT-licensed object mapper for &lt;strong&gt;.NET 8, 9, and 10&lt;/strong&gt;. You call &lt;code&gt;mapper.Map&amp;lt;UserDto&amp;gt;(user)&lt;/code&gt; and it copies matching members. Profiles still exist when names are not enough&lt;/a&gt;. &lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/stevenkamwaza/twinify-102-a-look-at-the-latest-performance-improvements-lap&quot;&gt;1.0.2 was about speed. &lt;strong&gt;1.1.0 is about less boilerplate&lt;/strong&gt;, safer DTOs, and &lt;strong&gt;Native AOT&lt;/strong&gt;.&lt;br&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;dotnet add package Twinify
dotnet add package Twinify.DependencyInjection
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NuGet: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Twinify&quot;&gt;Twinify&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-idea&quot;&gt;The idea&lt;/h3&gt;
&lt;p&gt;You still inject &lt;code&gt;IMapper&lt;/code&gt; and you still call &lt;code&gt;Map&lt;/code&gt;. Existing profile-only apps keep the same behaviour. Everything below is additive.&lt;/p&gt;
&lt;h3 id=&quot;1-mapping-without-a-profile&quot;&gt;1. Mapping without a profile&lt;/h3&gt;
&lt;p&gt;Matching names used to still need &lt;code&gt;CreateMap&lt;/code&gt;. Not anymore.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddTwinify();
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserService(IMapper mapper)
{
    public UserDto ToDto(User user) =&amp;gt; mapper.Map&amp;lt;UserDto&amp;gt;(user);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;CreateMap&lt;/code&gt;. Twinify copies same-name members, flattens &lt;code&gt;Address.City&lt;/code&gt; → &lt;code&gt;AddressCity&lt;/code&gt;, and walks nested objects and collections.&lt;/p&gt;
&lt;p&gt;No DI? Same thing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var mapper = new Mapper();
var dto = mapper.Map&amp;lt;UserDto&amp;gt;(user);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;2-keep-your-profiles-auto-map-the-rest-&quot;&gt;2. Keep your profiles. Auto-map the rest.&lt;/h3&gt;
&lt;p&gt;Registering a profile is still classic Twinify: only declared top-level pairs map.&lt;/p&gt;
&lt;p&gt;Want both? One flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddTwinify(cfg =&amp;gt; cfg.AddProfile&amp;lt;UserProfile&amp;gt;())
    .AllowMappingWithoutProfiles();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;CreateMap&lt;/code&gt; always wins for that pair. Nested types inside an explicit map still auto-map — you do not need &lt;code&gt;CreateMap&amp;lt;Address, AddressDto&amp;gt;()&lt;/code&gt; just because &lt;code&gt;User&lt;/code&gt; has an &lt;code&gt;Address&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;3-attributes-instead-of-another-formember&quot;&gt;3. Attributes instead of another &lt;code&gt;ForMember&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hide secrets&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserDto
{
    public int Id { get; set; }
    public string Name { get; set; } = &quot;&quot;;

    [IgnoreMap]
    public string? Password { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rename or flatten without a profile&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserDto
{
    [MapFrom(&quot;Address.City&quot;)]
    public string Town { get; set; } = &quot;&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pick a constructor&lt;/strong&gt; when the destination has more than one: &lt;code&gt;[MapConstructor]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Need to turn attributes off? &lt;code&gt;UseMappingAttributes(false)&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;4-native-aot-a-source-generator-in-the-same-package&quot;&gt;4. Native AOT: a source generator in the same package&lt;/h3&gt;
&lt;p&gt;JIT apps still compile maps at runtime. Native AOT cannot do that.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Twinify&lt;/code&gt; package now ships &lt;strong&gt;Twinify.SourceGenerator&lt;/strong&gt;. Mark the pair; Twinify emits a static, reflection-free map at compile time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[Twinify(typeof(User))]
public class UserDto
{
    public int Id { get; set; }
    public string Name { get; set; } = &quot;&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or at assembly scope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[assembly: TwinifyMap(typeof(User), typeof(UserDto))]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Uncustomized &lt;code&gt;CreateMap&lt;/code&gt; in a profile constructor can be generated too. Nested convention-compatible members of a generated pair are included.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; fluent &lt;code&gt;ForMember&lt;/code&gt; / &lt;code&gt;ConvertUsing&lt;/code&gt; are &lt;strong&gt;not&lt;/strong&gt; generated. Prefer &lt;code&gt;[MapFrom]&lt;/code&gt; / &lt;code&gt;[IgnoreMap]&lt;/code&gt; on AOT, or keep those pairs on JIT. A pair with no generated map throws &lt;code&gt;MappingNotFoundException&lt;/code&gt; on Native AOT.&lt;/p&gt;
&lt;p&gt;That is the 1.0.2 “what’s next” item, shipped.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-records-in-place-map-fail-at-boot&quot;&gt;5. Records, in-place map, fail at boot&lt;/h3&gt;
&lt;p&gt;Records bind constructor parameters by name. Extra settable properties still copy after construction.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public record Person(int Id, string Name);
public record PersonDto(int Id, string Name);

var dto = mapper.Map&amp;lt;PersonDto&amp;gt;(new Person(1, &quot;Ada&quot;));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Update an existing instance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;mapper.Map(user, existingDto);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Catch bad maps at startup instead of on the first request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddTwinify(cfg =&amp;gt; cfg.AddProfile&amp;lt;UserProfile&amp;gt;())
    .ValidateMappings();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Still stuck? &lt;code&gt;mapper.Explain&amp;lt;User, UserDto&amp;gt;()&lt;/code&gt; shows where each destination member gets its value.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;upgrade-path&quot;&gt;Upgrade path&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You have &lt;/th&gt;&lt;th&gt;You do&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Profile-only 1.0.x app &lt;/td&gt;&lt;td&gt;Update the package. Behaviour stays the same.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tired of &lt;code&gt;CreateMap&lt;/code&gt; for identical DTOs &lt;/td&gt;&lt;td&gt;&lt;code&gt;AllowMappingWithoutProfiles()&lt;/code&gt; or drop profiles for those pairs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Native AOT / trimming &lt;/td&gt;&lt;td&gt;Add &lt;code&gt;[Twinify]&lt;/code&gt; or &lt;code&gt;[TwinifyMap]&lt;/code&gt; and prefer attributes over fluent customizations.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;IMapper&lt;/code&gt; and &lt;code&gt;Map&amp;lt;TDestination&amp;gt;(source)&lt;/code&gt; did not change.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;dotnet add package Twinify --version 1.1.0
dotnet add package Twinify.DependencyInjection --version 1.1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Twinify&quot;&gt;https://www.nuget.org/packages/Twinify&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Release notes: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/StevenKamwaza/Twinify.Docs/releases/tag/1.1.0&quot;&gt;Twinify 1.1.0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you map DTOs in ASP.NET Core, workers, or AOT apps, I would love a try, a star, or a comment about the pair that still feels too noisy.&lt;/p&gt;
&lt;p&gt;Twinify — object mapping without the ceremony.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27216/twinify-1-1-0-map-without-profiles-hide-secrets-with-attributes-and-ship-native-aot</guid>
<pubDate>Tue, 15 Sep 2026 05:17:00 +0000</pubDate>
</item>
<item>
<title>C# API CRUD Done Wrong, Then Done Right: A Complete Bad-Practices-vs-Best-Practices Walkthrough</title>
<link>https://coderlegion.com/26211/c-api-crud-done-wrong-then-done-right-complete-bad-practices-best-practices-walkthrough</link>
<description>&lt;p&gt;Most bad-code examples in tutorials are contrived - obviously wrong in a way real code never actually looks. This post takes a different approach: a complete, realistic Product CRUD API, built with thirteen anti-patterns that genuinely show up in codebases. Each endpoint is walked through one at a time - the bad version, exactly what's wrong with it and why, then the good version, right next to each other, so nothing needs to be hunted down elsewhere on the page.&lt;/p&gt;
&lt;p&gt;The scope: a Product entity, five CRUD endpoints - GetAll, GetById, Create, Update, Delete.&lt;/p&gt;
&lt;h3 id=&quot;shared-setup-program-cs&quot;&gt;Shared Setup: Program.cs&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
builder.Services.AddSingleton&amp;lt;ProductsController&amp;gt;();
 
var app = builder.Build();
app.MapControllers();
app.Run();
 
// Inside ProductsController itself:
private readonly string _connectionString =
    &quot;Server=prod-sql-01;Database=Products;User Id=sa;Password=Passw0rd123!;&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Hardcoded connection string. A literal password is committed directly into source code. Anyone with repository access, including, eventually, anyone the repository is ever accidentally exposed to, has the production database password in plain text. &lt;br&gt;
The fix: load the connection string from configuration, appsettings.json locally, environment variables or Key Vault in production, never as a literal string in a class.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Incorrect service lifetime, AddSingleton for a controller. Manually registering the controller itself with AddSingleton means exactly one instance of ProductsController is created and shared across every single request for the entire lifetime of the application, rather than the framework creating a fresh instance per request the normal way. This becomes genuinely dangerous the moment that controller, or anything it depends on, holds any per-request state, and it gets worse once a Scoped dependency like a DbContext enters the picture, since DbContext is explicitly not thread-safe and is designed to live for exactly one request. A Singleton capturing a Scoped dependency is a well-known anti-pattern called a &quot;captive dependency&quot; - the Scoped service effectively gets trapped inside the Singleton's lifetime, silently becoming a de facto singleton itself, shared across requests and threads it was never designed to be shared across. &lt;br&gt;
The fix: never manually register a controller at all, AddControllers() already handles controller instantiation correctly per request, and give each dependency the lifetime that actually matches how it's meant to be used, which for something wrapping a DbContext means Scoped.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddDbContext&amp;lt;ProductDbContext&amp;gt;(options =&amp;gt;
    options.UseSqlServer(
        builder.Configuration.GetConnectionString(&quot;ProductsDb&quot;)));
 
builder.Services.AddScoped&amp;lt;IProductService, ProductService&amp;gt;();
builder.Services.AddControllers();
 
var app = builder.Build();
app.MapControllers();
app.Run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The connection string now comes from configuration. IProductService is registered as Scoped, not Singleton, one instance per HTTP request, matching the lifetime of the DbContext it wraps, and avoiding the captive dependency problem entirely. The controller itself is never manually registered at all; AddControllers() handles that correctly on its own.&lt;/p&gt;
&lt;h3 id=&quot;shared-setup-the-product-entity&quot;&gt;Shared Setup: The Product Entity&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public string InternalSupplierNotes { get; set; }
    public decimal CostPrice { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Identical in both versions - the entity itself isn't the problem. InternalSupplierNotes and CostPrice are genuinely internal fields, what the business actually pays a supplier, never meant for a customer-facing API to expose. What differs between versions is whether this exact class ever gets returned to a caller directly, covered in the GetById section below.&lt;/p&gt;
&lt;h3 id=&quot;endpoint-1-getall&quot;&gt;Endpoint 1: GetAll&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpGet]
public IActionResult GetAll()
{
    var products = GetAllProductsAsync().Result;
    return Ok(products);
}
 
private async Task&amp;lt;List&amp;lt;Product&amp;gt;&amp;gt; GetAllProductsAsync()
{
    var products = new List&amp;lt;Product&amp;gt;();
    using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync();
 
    var command = new SqlCommand(&quot;SELECT * FROM Products&quot;, connection);
    var reader = await command.ExecuteReaderAsync();
 
    while (await reader.ReadAsync())
    {
        products.Add(new Product
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Price = reader.GetDecimal(2)
        });
    }
    return products;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Missing await, .Result blocks the thread. GetAllProductsAsync().Result synchronously blocks the calling thread while waiting for the async method to complete, defeating the entire purpose of async and await, since the thread sits idle instead of being released back to the pool. In certain synchronization contexts, classic ASP.NET, WPF, WinForms, this specific pattern can cause a genuine deadlock. &lt;br&gt;
The fix: use await all the way up the call chain, never call .Result or .Wait() on an async method.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Undisposed SqlCommand and SqlDataReader. Both command and reader implement IDisposable, but only connection gets a using statement here. SqlDataReader in particular keeps the underlying connection in a busy state until it's explicitly closed, so under real load, with many concurrent requests each doing this, connections return to the pool later than they need to, risking pool exhaustion faster than necessary. The outer using on connection happens to clean these up indirectly when it disposes, which is what keeps this specific method from actively breaking right now, but relying on that indirect cleanup rather than disposing each IDisposable explicitly is fragile, and won't hold up if the method returns early or the pattern changes later. &lt;br&gt;
The fix: wrap every IDisposable in its own using statement, not just the outermost one.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpGet]
public async Task&amp;lt;ActionResult&amp;lt;List&amp;lt;ProductDto&amp;gt;&amp;gt;&amp;gt; GetAll()
{
    var products = await _productService.GetAllAsync();
    return Ok(products);
}
 
// Inside ProductService:
public async Task&amp;lt;List&amp;lt;ProductDto&amp;gt;&amp;gt; GetAllAsync()
{
    return await _context.Products
        .Select(p =&amp;gt; new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price,
            StockQuantity = p.StockQuantity
        })
        .ToListAsync();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Genuinely awaited from the controller all the way through the service call. EF Core's LINQ query is also parameterized automatically and projects directly to ProductDto - a preview of the DTO pattern the next endpoint introduces properly. There's also no manual connection, command, or reader to dispose at all here - EF Core's DbContext and its query execution manage the underlying ADO.NET resources internally, which is one more reason the disposal issue from the bad version simply can't occur in this version.&lt;/p&gt;
&lt;h3 id=&quot;endpoint-2-getbyid&quot;&gt;Endpoint 2: GetById&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpGet(&quot;{id}&quot;)]
public IActionResult GetById(int id)
{
    using var connection = new SqlConnection(_connectionString);
    connection.Open();
 
    var query = &quot;SELECT * FROM Products WHERE Id = &quot; + id;
    var command = new SqlCommand(query, connection);
    var reader = command.ExecuteReader();
 
    if (!reader.Read())
    {
        return Ok(null);
    }
 
    var product = new Product
    {
        Id = reader.GetInt32(0),
        Name = reader.GetString(1),
        Price = reader.GetDecimal(2)
    };
 
    return Ok(product);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;SQL injection. The id parameter is concatenated directly into the query string. A request to /api/products/1;DROP TABLE Products-- executes exactly that. &lt;br&gt;
The fix: never build SQL by concatenating user input, use a parameterized query, or an ORM like EF Core that parameterizes automatically.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ok(null) instead of NotFound(). When the product doesn't exist, this returns a 200 OK with an empty body. The caller can't distinguish &quot;found nothing&quot; from &quot;succeeded, and the answer happens to be nothing,&quot; both look identical on the wire. &lt;br&gt;
The fix: return NotFound(), a genuine 404, so the status code itself carries the information.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No DTO, full entity exposed. The returned product is the raw entity, including InternalSupplierNotes and CostPrice, fields that should never reach an API caller. &lt;br&gt;
The fix: map to a ProductDto containing only the fields meant to be public.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
}
 
[HttpGet(&quot;{id}&quot;)]
public async Task&amp;lt;ActionResult&amp;lt;ProductDto&amp;gt;&amp;gt; GetById(int id)
{
    var product = await _productService.GetByIdAsync(id);
 
    if (product == null)
        return NotFound();
 
    return Ok(product);
}
 
// Inside ProductService:
public async Task&amp;lt;ProductDto?&amp;gt; GetByIdAsync(int id)
{
    var product = await _context.Products.FindAsync(id);
    // FindAsync uses the primary key safely - parameterized
    // automatically, no string concatenation anywhere
 
    if (product == null)
        return null;
    // Returning null here is fine - it's the CONTROLLER's
    // job to translate this into the correct HTTP response,
    // not the service's
 
    return new ProductDto
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price,
        StockQuantity = product.StockQuantity
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All three issues fixed together: FindAsync makes SQL injection structurally impossible, NotFound() makes the response honest, and ProductDto makes leaking internal fields impossible rather than something to remember not to do.&lt;/p&gt;
&lt;h3 id=&quot;endpoint-3-create&quot;&gt;Endpoint 3: Create&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpPost]
public IActionResult Create(Product product)
{
    using var connection = new SqlConnection(_connectionString);
    connection.Open();
 
    var query = $&quot;INSERT INTO Products (Name, Price) &quot; +
                 $&quot;VALUES ('{product.Name}', {product.Price})&quot;;
    var command = new SqlCommand(query, connection);
    command.ExecuteNonQuery();
 
    _logger.LogInformation(
        &quot;Product created by user {Email} with auth token {Token}: {Name}&quot;,
        currentUser.Email, currentUser.AuthToken, product.Name);
 
    return Ok(product);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;No validation. A request with an empty Name, a negative Price, or missing fields is accepted exactly as-is. &lt;br&gt;
The fix: add validation attributes to a dedicated input DTO, paired with ApiController's automatic model validation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SQL injection, again. Same pattern as GetById, product.Name concatenated directly, and this version breaks entirely if the name contains a single quote. &lt;br&gt;
The fix: same as before, EF Core, parameterized automatically.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wrong status code. Returns 200 OK for a successful creation. &lt;br&gt;
The fix: 201 Created, with a Location header pointing to the new resource, since 200 doesn't communicate &quot;a new thing was created.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sensitive data logged in plain text. The user's email is reasonable to log, but the auth token is a genuine secret, and it's now sitting in plain text inside Application Insights or Log Analytics, visible to anyone with read access to logs, often retained for weeks or months. A leaked or overly-permissioned log query becomes a credential leak. &lt;br&gt;
The fix: never log secrets, tokens, passwords, or other sensitive fields, log an identifier (a user ID) instead of the credential itself, and if a field must be referenced for debugging, mask or redact it before it reaches the logger.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class CreateProductDto
{
    [Required, MaxLength(200)]
    public string Name { get; set; }
 
    [Range(0.01, 1000000)]
    public decimal Price { get; set; }
 
    [Range(0, int.MaxValue)]
    public int StockQuantity { get; set; }
}
 
[HttpPost]
public async Task&amp;lt;ActionResult&amp;lt;ProductDto&amp;gt;&amp;gt; Create(CreateProductDto dto)
{
    // No manual validation check needed - [ApiController]
    // automatically returns 400 Bad Request if the DTO's
    // validation attributes aren't satisfied, before this
    // method body even runs
 
    var created = await _productService.CreateAsync(dto);
 
    _logger.LogInformation(
        &quot;Product {ProductId} created by user {UserId}&quot;,
        created.Id, currentUser.Id);
    // Logs an IDENTIFIER, never a credential or token -
    // enough to trace who did what, without exposing
    // anything an attacker or an over-permissioned log
    // reader could actually use
 
    return CreatedAtAction(
        nameof(GetById),
        new { id = created.Id },
        created);
}
 
// Inside ProductService:
public async Task&amp;lt;ProductDto&amp;gt; CreateAsync(CreateProductDto dto)
{
    var product = new Product
    {
        Name = dto.Name,
        Price = dto.Price,
        StockQuantity = dto.StockQuantity
    };
 
    _context.Products.Add(product);
    await _context.SaveChangesAsync();
 
    return new ProductDto
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price,
        StockQuantity = product.StockQuantity
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CreateProductDto's Required, MaxLength, and Range attributes are checked automatically by ApiController. CreatedAtAction returns the correct 201 with a Location header built from the GetById action. The log statement now records a user ID, not a credential, and EF Core removes the SQL injection path entirely.&lt;/p&gt;
&lt;h3 id=&quot;endpoint-4-update&quot;&gt;Endpoint 4: Update&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpPut(&quot;{id}&quot;)]
public IActionResult Update(int id, Product product)
{
    try
    {
        using var connection = new SqlConnection(_connectionString);
        connection.Open();
 
        var query = $&quot;UPDATE Products SET Name = '{product.Name}', &quot; +
                    $&quot;Price = {product.Price} WHERE Id = {id}&quot;;
        var command = new SqlCommand(query, connection);
        command.ExecuteNonQuery();
    }
    catch (Exception ex)
    {
        return Ok(new { error = ex.ToString() });
    }
 
    return Ok();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Swallowed exception behavior masked as a response. The catch block doesn't silently discard the exception here, but it does something almost as problematic in a different way, it converts a failure into a 200 OK response body, meaning the HTTP status code itself claims success while the actual content says otherwise. Callers checking only the status code, which is the normal way to check for success, will treat this as a successful update. &lt;br&gt;
The fix: return an actual error status code, and don't rely on the response body alone to communicate failure.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SQL injection, again. Same string-concatenation pattern as the previous two endpoints.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Full exception details leaked to the caller. ex.ToString() returns the complete exception message and full stack trace, including internal file paths, method names, and sometimes literal fragments of the failed SQL query itself, all sent directly to whoever called this endpoint. This is genuinely useful reconnaissance for an attacker probing the API, and it's an accidental disclosure of internal implementation detail even to a well-meaning caller. &lt;br&gt;
The fix: log the full exception detail internally, server-side, and return only a generic, safe error message to the caller.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpPut(&quot;{id}&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; Update(int id, CreateProductDto dto)
{
    var success = await _productService.UpdateAsync(id, dto);
 
    if (!success)
        return NotFound();
 
    return NoContent();
}
 
// Inside ProductService:
public async Task&amp;lt;bool&amp;gt; UpdateAsync(int id, CreateProductDto dto)
{
    var product = await _context.Products.FindAsync(id);
    if (product == null)
        return false;
 
    product.Name = dto.Name;
    product.Price = dto.Price;
    product.StockQuantity = dto.StockQuantity;
 
    try
    {
        await _context.SaveChangesAsync();
        return true;
    }
    catch (DbUpdateException ex)
    {
        // The FULL exception, including stack trace, is
        // logged INTERNALLY here - visible to the team via
        // Application Insights, never sent to the caller
        _logger.LogError(ex, &quot;Failed to update product {Id}&quot;, id);
        return false;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exception is now caught narrowly, DbUpdateException, not the broad Exception base class, logged internally in full detail with _logger.LogError, and the method returns false so the controller responds with a correct NotFound or, in a fuller version, a generic 500 with a safe message, never the raw exception content itself.&lt;/p&gt;
&lt;h3 id=&quot;endpoint-5-delete&quot;&gt;Endpoint 5: Delete&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bad version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpDelete(&quot;{id}&quot;)]
public IActionResult Delete(int id)
{
    using var connection = new SqlConnection(_connectionString);
    connection.Open();
 
    var query = &quot;DELETE FROM Products WHERE Id = &quot; + id;
    var command = new SqlCommand(query, connection);
    command.ExecuteNonQuery();
 
    return Ok();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Issues:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;SQL injection, again. The same concatenation pattern as every other endpoint above.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wrong status code. Returns 200 OK with no body for a successful delete. The fix: 204 No Content, the correct response when an operation succeeds and there's genuinely nothing further to return.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Good version:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;[HttpDelete(&quot;{id}&quot;)]
public async Task&amp;lt;IActionResult&amp;gt; Delete(int id)
{
    var success = await _productService.DeleteAsync(id);
 
    if (!success)
        return NotFound();
 
    return NoContent();
}
 
// Inside ProductService:
public async Task&amp;lt;bool&amp;gt; DeleteAsync(int id)
{
    var product = await _context.Products.FindAsync(id);
    if (product == null)
        return false;
 
    _context.Products.Remove(product);
    await _context.SaveChangesAsync();
    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-complete-good-version-service-interface&quot;&gt;The Complete Good-Version Service Interface&lt;/h3&gt;
&lt;p&gt;Pulling every service method from above together into the one interface the controller actually depends on:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public interface IProductService
{
    Task&amp;lt;List&amp;lt;ProductDto&amp;gt;&amp;gt; GetAllAsync();
    Task&amp;lt;ProductDto?&amp;gt; GetByIdAsync(int id);
    Task&amp;lt;ProductDto&amp;gt; CreateAsync(CreateProductDto dto);
    Task&amp;lt;bool&amp;gt; UpdateAsync(int id, CreateProductDto dto);
    Task&amp;lt;bool&amp;gt; DeleteAsync(int id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the layer that never existed in the bad version at all, the bad controller talked directly to SqlConnection. Having this interface is also what makes the controller genuinely unit-testable with a mock, something effectively impossible against the bad version's design.&lt;/p&gt;
&lt;h3 id=&quot;attribute-reference-what-s-doing-the-actual-work&quot;&gt;Attribute Reference: What's Doing the Actual Work&lt;/h3&gt;
&lt;p&gt;ApiController enables automatic model validation, checking a DTO's validation attributes and returning 400 automatically if they fail, before the action method body runs. Present in both versions, but only doing real work in the good one, since the bad version's Product parameter has no validation attributes for it to check.&lt;/p&gt;
&lt;p&gt;Route with the controller token sets the base route, &quot;api/products,&quot; identical in both versions.&lt;/p&gt;
&lt;p&gt;HttpGet, the id-scoped HttpGet, HttpPost, the id-scoped HttpPut, and the id-scoped HttpDelete map each method to its HTTP verb and route, the routing itself was never the problem in the bad version.&lt;/p&gt;
&lt;p&gt;Required, MaxLength, and Range on CreateProductDto are declarative validation rules, good version only, paired with ApiController to reject invalid requests automatically, with zero manual if-checks.&lt;/p&gt;
&lt;h3 id=&quot;every-issue-at-a-glance&quot;&gt;Every Issue, At a Glance&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Hardcoded connection string, in Program.cs and setup. &lt;br&gt;
Bad: literal password committed in source code. &lt;br&gt;
Good: loaded from configuration or Key Vault.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Incorrect service lifetime, AddSingleton for a controller, in Program.cs and setup. &lt;br&gt;
Bad: the controller manually registered as Singleton, one shared instance forever, risking a captive dependency once a Scoped DbContext enters the picture. &lt;br&gt;
Good: the controller is never manually registered at all, and IProductService is registered as Scoped, matching the DbContext's own lifetime.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Missing await / .Result, in GetAll. &lt;br&gt;
Bad: .Result blocks the thread synchronously. &lt;br&gt;
Good: await released the thread properly, all the way through.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Undisposed SqlCommand and SqlDataReader, in GetAll. &lt;br&gt;
Bad: only connection wrapped in a using, command and reader never explicitly disposed, relying on indirect cleanup that isn't reliable. &lt;br&gt;
Good: EF Core manages the underlying ADO.NET resources internally, so there's no manual disposal to get wrong.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SQL injection, appearing in GetById, Create, Update, and Delete. &lt;br&gt;
Bad: string-concatenated raw SQL. &lt;br&gt;
Good: EF Core LINQ or FindAsync, parameterized automatically.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ok(null) instead of NotFound(), in GetById. &lt;br&gt;
Bad: 200 OK with a null body when not found. &lt;br&gt;
Good: NotFound(), a genuine 404.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No DTOs, full entity exposed, in GetById. &lt;br&gt;
Bad: internal fields like CostPrice and supplier notes exposed. &lt;br&gt;
Good: ProductDto excludes them entirely, structurally impossible to leak.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No validation, in Create. &lt;br&gt;
Bad: any data accepted as-is. &lt;br&gt;
Good: Required, MaxLength, and Range attributes plus ApiController auto-reject invalid requests.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wrong status codes, in Create and Delete. &lt;br&gt;
Bad: 200 OK for create and delete. &lt;br&gt;
Good: 201 Created with a Location header for create, 204 No Content for delete.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sensitive data logged in plain text, in Create. &lt;br&gt;
Bad: an auth token logged directly, sitting in plain text in Application Insights. &lt;br&gt;
Good: only a user ID logged, never a credential or token.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Swallowed exception behavior masked as a response, in Update. &lt;br&gt;
Bad: a failure converted into a 200 OK response body, status code claims success while content says otherwise. &lt;br&gt;
Good: an honest result returned, false propagated up so the controller can respond correctly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Full exception details leaked to the caller, in Update. &lt;br&gt;
Bad: ex.ToString(), including the full stack trace, returned directly in the API response. &lt;br&gt;
Good: the full exception logged internally only, a generic safe message, if any, returned to the caller.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No service layer, structural, underneath all endpoints. &lt;br&gt;
Bad: controller does raw data access directly. &lt;br&gt;
Good: IProductService and ProductService, separated, testable, the natural home for every other fix.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;key-lessons&quot;&gt;Key Lessons&lt;/h3&gt;
&lt;p&gt;Every anti-pattern here is realistic, these are mistakes that genuinely appear in real production code, not exaggerated examples built just to be wrong.&lt;/p&gt;
&lt;p&gt;The missing service layer is structurally connected to most of the other issues, a controller doing raw data access directly removes the natural place validation, parameterization, and error handling would otherwise live.&lt;/p&gt;
&lt;p&gt;A DTO isn't just extra code, it's what makes leaking internal fields structurally impossible, rather than something a developer has to remember not to do.&lt;/p&gt;
&lt;p&gt;Status codes are information, not decoration, Ok(null), 200 for a create, and 200 for a delete each communicate something genuinely false or ambiguous to the caller.&lt;/p&gt;
&lt;p&gt;Logging and error responses are both genuine attack surfaces, not just debugging conveniences, a logged token or a leaked stack trace can hand an attacker exactly what they need, even when the code otherwise &quot;works.&quot;&lt;/p&gt;
&lt;p&gt;A swallowed exception, or one disguised as a success response, is worse than a crash, a crash is visible immediately, a silently hidden failure looks like success and can go unnoticed for a long time.&lt;/p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;Every issue in the bad version compiles, runs, and looks like reasonable code at a glance, which is exactly why these patterns survive in real codebases far longer than obviously broken code ever would. Rebuilding the same five endpoints with a real service layer, correct dependency lifetimes, disciplined resource disposal, DTOs, parameterized queries, genuine async, proper validation, correct status codes, and careful logging doesn't just fix thirteen individual bugs, it changes the shape of the code so that several of those bugs become structurally difficult to reintroduce by accident.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Originally published at TechStack Blog: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/post.html?slug=csharp-api-crud-bad-vs-good-practices&quot;&gt;https://www.techstackblog.com/post.html?slug=csharp-api-crud-bad-vs-good-practices&lt;/a&gt; &lt;br&gt;
More from TechStack Blog: C# / .NET: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/category.html?cat=csharp&quot;&gt;https://www.techstackblog.com/category.html?cat=csharp&lt;/a&gt;&lt;br&gt;
CS Fundamentals: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/category.html?cat=cs-fundamentals&quot;&gt;https://www.techstackblog.com/category.html?cat=cs-fundamentals&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26211/c-api-crud-done-wrong-then-done-right-complete-bad-practices-best-practices-walkthrough</guid>
<pubDate>Fri, 04 Sep 2026 14:12:06 +0000</pubDate>
</item>
<item>
<title>C# Coding Interview Prep Part 1: Variables, Strings, Arrays, Lists, Dictionaries, and Every Basic Building Block</title>
<link>https://coderlegion.com/26210/coding-interview-variables-strings-arrays-lists-dictionaries-every-basic-building-block</link>
<description>&lt;p&gt;This is a refresher built while preparing for C# coding interviews, and shared as a resource, because a genuinely comprehensive, practice-oriented guide is hard to find in one place. This series doesn't skip the basics to get to the &quot;interesting&quot; parts - the basics are exactly what trips people up under interview pressure, when a simple question about arrays suddenly requires recalling details nobody thinks about in day-to-day coding. Every topic in this series follows the same format: a plain-English explanation, an analogy, a code example, and a practice question at the end, so this can genuinely be used to test yourself, not just read passively.&lt;/p&gt;
&lt;p&gt;This is Part 1 of three. Part 1 covers the &lt;strong&gt;&lt;em&gt;basics &lt;/em&gt;&lt;/strong&gt;- variables, data types, strings, arrays, List, Dictionary&amp;lt;TKey,TValue&amp;gt;, HashSet, tuples, value vs reference types, operators, control flow, loops, methods, enums, structs vs classes, nullable types, type conversion, and basic exception handling. Part 2 covers &lt;strong&gt;&lt;em&gt;mid-level&lt;/em&gt;&lt;/strong&gt; concepts - LINQ, generics, delegates and events, interfaces vs abstract classes, and exception handling in depth. Part 3 covers &lt;strong&gt;&lt;em&gt;advanced &lt;/em&gt;&lt;/strong&gt;concepts - async and Task, reflection, memory management and garbage collection, design patterns in practice, and performance considerations.&lt;/p&gt;
&lt;h3 id=&quot;topic-1-variables-and-data-types&quot;&gt;Topic 1: Variables and Data Types&lt;/h3&gt;
&lt;p&gt;A variable is a named storage location holding a value of a specific type. C# is statically typed - once a variable is declared with a type, it can only ever hold values of that type, or something compatible with it.&lt;/p&gt;
&lt;p&gt;Think of a labeled storage box. A box labeled &quot;int&quot; can only hold integers - trying to put a sentence in it doesn't work, the same way a shoebox labeled for shoes doesn't neatly fit a stack of books.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Value types - the actual data lives directly
// in the variable
int age = 30;
double price = 19.99;
bool isPublished = true;
char grade = 'A';
decimal exactMoney = 19.99m;  // decimal for money -
                               // no floating point
                               // rounding errors
 
// var - the compiler infers the type at compile
// time, this is still statically typed, NOT dynamic
var name = &quot;Alex&quot;;  // compiler infers string
 
// Reference types - the variable holds a reference
// (address) to where the actual data lives
string title = &quot;C# Basics&quot;;
int[] numbers = { 1, 2, 3 };
Post post = new Post();
 
// Constants - value is fixed at compile time,
// cannot ever change
const double Pi = 3.14159;
 
// Nullable value types - value types normally
// cannot be null, this opts in
int? nullableAge = null;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: What is the difference between const and readonly in C#? Write one example of each.&lt;/p&gt;
&lt;h3 id=&quot;topic-2-strings-and-string-methods&quot;&gt;Topic 2: Strings and String Methods&lt;/h3&gt;
&lt;p&gt;A string is a sequence of characters, and in C# it's a reference type - but with a critical twist: strings are immutable. Once created, a string's actual content in memory never changes. Every operation that appears to modify a string actually creates a brand new string.&lt;/p&gt;
&lt;p&gt;Think of a printed book page. You cannot erase and rewrite a word on a printed page - if you want different text, you print an entirely new page. Modifying a string works the same way: the old string still exists in memory until garbage collected, and a new one is created for the result.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;string greeting = &quot;Hello&quot;;
string name = &quot;Manohari&quot;;
 
// Concatenation - creates a NEW string, does not
// modify 'greeting'
string message = greeting + &quot;, &quot; + name;
 
// String interpolation - cleaner syntax, same result
string message2 = $&quot;{greeting}, {name}&quot;;
 
// Common string methods
string text = &quot;  C# Interview Prep  &quot;;
Console.WriteLine(text.Trim());           // &quot;C# Interview Prep&quot;
Console.WriteLine(text.ToUpper());        // &quot;  C# INTERVIEW PREP  &quot;
Console.WriteLine(text.Contains(&quot;Prep&quot;)); // true
Console.WriteLine(text.Replace(&quot;C#&quot;, &quot;CSharp&quot;));
Console.WriteLine(text.Split(' ').Length);
Console.WriteLine(text.Substring(2, 4));  // extracts a portion
Console.WriteLine(text.IndexOf(&quot;Prep&quot;));  // position found at
 
// String comparison - == checks VALUE equality
// for strings specifically (special-cased), not
// reference equality
string a = &quot;hello&quot;;
string b = &quot;hello&quot;;
Console.WriteLine(a == b);         // true - value equality
Console.WriteLine(a.Equals(b));    // true - same result
 
// StringBuilder - for heavy concatenation, avoid
// creating thousands of throwaway strings in a loop
var sb = new StringBuilder();
for (int i = 0; i &amp;lt; 1000; i++)
{
    sb.Append(i).Append(&quot;, &quot;);
}
string result = sb.ToString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a method that reverses a string without using any built-in reverse method.&lt;/p&gt;
&lt;h3 id=&quot;topic-3-arrays&quot;&gt;Topic 3: Arrays&lt;/h3&gt;
&lt;p&gt;An array is a fixed-size, ordered collection of elements of the same type. The size is set at creation and cannot change afterward - to &quot;resize,&quot; a new array must be created and the old data copied over.&lt;/p&gt;
&lt;p&gt;Think of a row of numbered parking spaces painted onto pavement. The number of spaces is fixed the moment the lot is built - adding more spaces means literally repaving with a new, larger layout, not just adding to the existing one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Declaration and initialization
int[] numbers = { 1, 2, 3, 4, 5 };
string[] names = new string[3];  // size 3, all null initially
names[0] = &quot;Alex&quot;;
names[1] = &quot;Priya&quot;;
names[2] = &quot;Sam&quot;;
 
// Accessing and modifying
Console.WriteLine(numbers[0]);   // 1
numbers[0] = 100;                // arrays ARE mutable in
                                  // content, just fixed size
 
// Iterating
foreach (var n in numbers)
{
    Console.WriteLine(n);
}
 
// Multi-dimensional array
int[,] grid = new int[3, 3];
grid[0, 0] = 1;
grid[1, 1] = 5;
 
// Jagged array - array of arrays, each inner
// array can be a different length
int[][] jagged = new int[3][];
jagged[0] = new int[] { 1, 2 };
jagged[1] = new int[] { 1, 2, 3, 4 };
 
// Common Array methods
Array.Sort(numbers);
Array.Reverse(numbers);
int index = Array.IndexOf(numbers, 100);
bool found = Array.Exists(numbers, n =&amp;gt; n &amp;gt; 50);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Given an integer array, write a method that finds the second largest number without sorting the entire array.&lt;/p&gt;
&lt;h3 id=&quot;topic-4-list&quot;&gt;Topic 4: List&lt;/h3&gt;
&lt;p&gt;List is a resizable, ordered collection - the generic, flexible alternative to a fixed-size array. Internally it's backed by an array that gets replaced with a larger one automatically as items are added beyond current capacity.&lt;/p&gt;
&lt;p&gt;Think of an expandable file folder versus a fixed shoebox. As more documents come in, the expandable folder just grows - you never have to buy a new one and manually move everything over, even though internally that's conceptually similar to what List does behind the scenes automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var posts = new List&amp;lt;string&amp;gt;();
 
posts.Add(&quot;First Post&quot;);
posts.Add(&quot;Second Post&quot;);
posts.Insert(0, &quot;Inserted at start&quot;);
 
posts.Remove(&quot;First Post&quot;);     // removes by value
posts.RemoveAt(0);               // removes by index
 
bool exists = posts.Contains(&quot;Second Post&quot;);
int index = posts.IndexOf(&quot;Second Post&quot;);
 
Console.WriteLine(posts.Count);  // current item count
 
// Iterating
foreach (var post in posts)
{
    Console.WriteLine(post);
}
 
// Converting between List and Array
int[] array = { 1, 2, 3 };
List&amp;lt;int&amp;gt; fromArray = array.ToList();
int[] backToArray = fromArray.ToArray();
 
// Sorting and searching
var numbers = new List&amp;lt;int&amp;gt; { 5, 2, 8, 1 };
numbers.Sort();
numbers.Sort((a, b) =&amp;gt; b.CompareTo(a));  // descending,
                                            // custom comparer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a method that removes all duplicate values from a List while preserving the original order.&lt;/p&gt;
&lt;h3 id=&quot;topic-5-dictionary-lt-tkey-tvalue-gt-&quot;&gt;Topic 5: Dictionary&amp;lt;TKey, TValue&amp;gt;&lt;/h3&gt;
&lt;p&gt;A Dictionary stores key-value pairs and provides fast, close-to-O(1) lookup by key, using a hash table internally. Every key must be unique.&lt;/p&gt;
&lt;p&gt;Think of a labeled filing cabinet drawer system, where every drawer has exactly one unique name on it. Finding &quot;the invoices drawer&quot; doesn't require checking every drawer one by one - you go directly to the one labeled correctly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var postsBySlug = new Dictionary&amp;lt;string, string&amp;gt;();
 
postsBySlug[&quot;csharp-basics&quot;] = &quot;C# Basics Post&quot;;
postsBySlug[&quot;csharp-linq&quot;] = &quot;LINQ Deep Dive&quot;;
 
// Safe lookup - does not throw if key is missing
if (postsBySlug.TryGetValue(&quot;csharp-basics&quot;, out var title))
{
    Console.WriteLine(title);
}
 
// Direct access - throws KeyNotFoundException if missing
string result = postsBySlug[&quot;csharp-basics&quot;];
 
bool hasKey = postsBySlug.ContainsKey(&quot;csharp-linq&quot;);
postsBySlug.Remove(&quot;csharp-linq&quot;);
 
// Iterating - gives KeyValuePair&amp;lt;TKey,TValue&amp;gt;
foreach (var kvp in postsBySlug)
{
    Console.WriteLine($&quot;{kvp.Key}: {kvp.Value}&quot;);
}
 
// Just keys or just values
foreach (var key in postsBySlug.Keys) { }
foreach (var value in postsBySlug.Values) { }
 
// Update or add in one call
postsBySlug[&quot;csharp-basics&quot;] = &quot;Updated Title&quot;;  // overwrites
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Given an array of words, write a method using a Dictionary that counts how many times each word appears.&lt;/p&gt;
&lt;h3 id=&quot;topic-6-hashset&quot;&gt;Topic 6: HashSet&lt;/h3&gt;
&lt;p&gt;A HashSet stores unique values only, with no guaranteed order, and provides fast O(1) average lookup for &quot;does this exist&quot; checks - much faster than checking a List with Contains for large collections.&lt;/p&gt;
&lt;p&gt;Think of a guest list at the door of an event, where a bouncer just needs to answer &quot;is this name already checked in&quot; as fast as possible - not caring about the order names were added, just whether a given name is present at all.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;var uniqueTags = new HashSet&amp;lt;string&amp;gt;();
 
uniqueTags.Add(&quot;Azure&quot;);
uniqueTags.Add(&quot;C#&quot;);
uniqueTags.Add(&quot;Azure&quot;);  // silently ignored - duplicate
 
Console.WriteLine(uniqueTags.Count);  // 2
 
bool hasAzure = uniqueTags.Contains(&quot;Azure&quot;);  // O(1) check
 
// Set operations
var setA = new HashSet&amp;lt;int&amp;gt; { 1, 2, 3, 4 };
var setB = new HashSet&amp;lt;int&amp;gt; { 3, 4, 5, 6 };
 
setA.IntersectWith(setB);  // setA becomes {3, 4}
// UnionWith, ExceptWith also available
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Given two integer arrays, write a method that returns only the numbers that appear in both arrays, using a HashSet.&lt;/p&gt;
&lt;h3 id=&quot;topic-7-tuples&quot;&gt;Topic 7: Tuples&lt;/h3&gt;
&lt;p&gt;A tuple bundles multiple values together into one object without needing to define a custom class. Modern C# tuples support named elements, making them readable at the point of use.&lt;/p&gt;
&lt;p&gt;Think of a small labeled tray carrying a few different items together - a tray with &quot;coffee&quot; and &quot;muffin&quot; compartments lets you carry both at once without needing a whole custom carrying case built just for that specific combination.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Named tuple
(string Name, int Age) person = (&quot;Alex&quot;, 30);
Console.WriteLine(person.Name);  // &quot;Alex&quot;
Console.WriteLine(person.Age);   // 30
 
// Returning multiple values from a method
(int Min, int Max) FindMinMax(int[] numbers)
{
    return (numbers.Min(), numbers.Max());
}
 
var result = FindMinMax(new[] { 5, 2, 8, 1 });
Console.WriteLine($&quot;Min: {result.Min}, Max: {result.Max}&quot;);
 
// Deconstruction
var (min, max) = FindMinMax(new[] { 5, 2, 8, 1 });
Console.WriteLine(min);
Console.WriteLine(max);
 
// Tuple vs custom class - when to use which
// Tuple: quick, internal, 2-3 short-lived values
// Class: the shape has real meaning, gets passed
// around broadly, or needs its own behavior/methods
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a method that takes a list of numbers and returns a tuple containing the sum, average, and count in one call.&lt;/p&gt;
&lt;h3 id=&quot;topic-8-value-types-vs-reference-types&quot;&gt;Topic 8: Value Types vs Reference Types&lt;/h3&gt;
&lt;p&gt;This is one of the most frequently tested distinctions in C# interviews. Value types - int, bool, double, struct - store their actual data directly in the variable. Reference types - class, array, string, List, Dictionary - store a reference, a memory address, to where the actual data lives on the heap. The variable itself just holds that address.&lt;/p&gt;
&lt;p&gt;Think of a value type as writing a number directly on a sticky note - the note is the data. A reference type is like writing a house address on a sticky note - the note points to where the actual house is, and copying the note just copies the address, not the house itself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Value type - assignment COPIES the actual value
int a = 5;
int b = a;   // b gets its OWN copy of 5
b = 10;
Console.WriteLine(a);  // still 5 - a was never touched
 
// Reference type - assignment copies the REFERENCE,
// both variables point to the SAME object
var list1 = new List&amp;lt;int&amp;gt; { 1, 2, 3 };
var list2 = list1;   // list2 points to the SAME list
list2.Add(4);
Console.WriteLine(list1.Count);  // 4 - list1 sees the
                                   // change too, because
                                   // it's the same object
 
// Passing to methods - same rule applies
void ModifyValue(int x) { x = 100; }
void ModifyReference(List&amp;lt;int&amp;gt; list) { list.Add(999); }
 
int number = 5;
ModifyValue(number);
Console.WriteLine(number);  // still 5
 
var myList = new List&amp;lt;int&amp;gt; { 1, 2 };
ModifyReference(myList);
Console.WriteLine(myList.Count);  // 3 - the SAME list
                                    // object was modified
 
// string is a special case - reference type, but
// immutable, so it BEHAVES like a value type in
// most practical situations
string s1 = &quot;hello&quot;;
string s2 = s1;
s2 = s2 + &quot; world&quot;;  // creates a NEW string for s2
Console.WriteLine(s1);  // still &quot;hello&quot; - unaffected
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Without running the code, predict the output: create a struct Point with a public int X, create var p1 = new Point with X set to 1, assign var p2 = p1, then set p2.X to 99. What is p1.X afterward, and why?&lt;/p&gt;
&lt;h3 id=&quot;topic-9-operators&quot;&gt;Topic 9: Operators&lt;/h3&gt;
&lt;p&gt;Operators perform operations on values - arithmetic, comparison, logical, and a few special ones worth knowing well for interviews specifically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Arithmetic
int sum = 5 + 3;
int remainder = 10 % 3;   // modulo - remainder after division
 
// Comparison
bool isEqual = (5 == 5);
bool isGreater = (5 &amp;gt; 3);
 
// Logical
bool result = true &amp;amp;&amp;amp; false;   // AND
bool result2 = true || false;  // OR
bool result3 = !true;           // NOT
 
// Null-coalescing - returns left if not null,
// otherwise returns right
string name = null;
string display = name ?? &quot;Unknown&quot;;  // &quot;Unknown&quot;
 
// Null-conditional - safely accesses a member,
// returns null instead of throwing if the object
// itself is null
Post post = null;
string title = post?.Title;  // null, no exception thrown
 
// Null-coalescing assignment
string cachedValue = null;
cachedValue ??= &quot;default&quot;;  // assigns only if currently null
 
// Ternary conditional
int age = 20;
string category = age &amp;gt;= 18 ? &quot;Adult&quot; : &quot;Minor&quot;;
 
// Increment/decrement
int count = 0;
count++;      // post-increment
++count;      // pre-increment - subtle difference in
              // expressions, same end result standalone
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: What is the difference between x++ and ++x when used inside a larger expression, like int y = x++ + 1 versus int y = ++x + 1? Write out what each assigns to x and y given x starts at 5.&lt;/p&gt;
&lt;h3 id=&quot;topic-10-control-flow---if-else-switch&quot;&gt;Topic 10: Control Flow - if, else, switch&lt;/h3&gt;
&lt;p&gt;Control flow statements decide which code actually runs based on conditions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int score = 85;
 
// if / else if / else
if (score &amp;gt;= 90)
{
    Console.WriteLine(&quot;A&quot;);
}
else if (score &amp;gt;= 80)
{
    Console.WriteLine(&quot;B&quot;);
}
else
{
    Console.WriteLine(&quot;C or below&quot;);
}
 
// switch statement
string grade;
switch (true)
{
    case bool _ when score &amp;gt;= 90:
        grade = &quot;A&quot;;
        break;
    case bool _ when score &amp;gt;= 80:
        grade = &quot;B&quot;;
        break;
    default:
        grade = &quot;C or below&quot;;
        break;
}
 
// switch expression (modern C#, more concise)
string grade2 = score switch
{
    &amp;gt;= 90 =&amp;gt; &quot;A&quot;,
    &amp;gt;= 80 =&amp;gt; &quot;B&quot;,
    _ =&amp;gt; &quot;C or below&quot;
};
 
// Pattern matching in switch
object value = 42;
string description = value switch
{
    int i when i &amp;gt; 0 =&amp;gt; &quot;positive integer&quot;,
    int i when i &amp;lt; 0 =&amp;gt; &quot;negative integer&quot;,
    string s =&amp;gt; $&quot;a string: {s}&quot;,
    null =&amp;gt; &quot;null value&quot;,
    _ =&amp;gt; &quot;something else&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a method using a switch expression that takes an integer representing a day of the week (1-7) and returns whether it's a &quot;Weekday&quot; or &quot;Weekend&quot;.&lt;/p&gt;
&lt;h3 id=&quot;topic-11-loops&quot;&gt;Topic 11: Loops&lt;/h3&gt;
&lt;p&gt;Loops repeat a block of code, either a fixed number of times, while a condition holds, or once per element in a collection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// for loop - when you know the iteration count
// or need the index
for (int i = 0; i &amp;lt; 5; i++)
{
    Console.WriteLine(i);
}
 
// while loop - condition checked BEFORE each iteration
int count = 0;
while (count &amp;lt; 5)
{
    Console.WriteLine(count);
    count++;
}
 
// do-while - condition checked AFTER each iteration,
// so the body ALWAYS runs at least once
int x = 10;
do
{
    Console.WriteLine(x);
    x++;
} while (x &amp;lt; 5);  // runs once even though condition
                    // is false from the start
 
// foreach - iterates every element in a collection,
// no manual index management
var posts = new List&amp;lt;string&amp;gt; { &quot;A&quot;, &quot;B&quot;, &quot;C&quot; };
foreach (var post in posts)
{
    Console.WriteLine(post);
}
 
// break and continue
for (int i = 0; i &amp;lt; 10; i++)
{
    if (i == 3) continue;  // skip this iteration
    if (i == 7) break;     // exit the loop entirely
    Console.WriteLine(i);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a program using a loop that prints the Fibonacci sequence up to the 10th number.&lt;/p&gt;
&lt;h3 id=&quot;topic-12-methods-and-parameters&quot;&gt;Topic 12: Methods and Parameters&lt;/h3&gt;
&lt;p&gt;A method is a named, reusable block of code. Parameters pass data into a method; a return type defines what comes back out.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Basic method
int Add(int a, int b)
{
    return a + b;
}
 
// Optional parameters - must come after required ones
void Greet(string name, string greeting = &quot;Hello&quot;)
{
    Console.WriteLine($&quot;{greeting}, {name}&quot;);
}
Greet(&quot;Alex&quot;);               // uses default &quot;Hello&quot;
Greet(&quot;Alex&quot;, &quot;Hi&quot;);         // overrides default
 
// Named arguments - can be passed in any order
Greet(greeting: &quot;Hey&quot;, name: &quot;Alex&quot;);
 
// params - accepts a variable number of arguments
int Sum(params int[] numbers)
{
    int total = 0;
    foreach (var n in numbers) total += n;
    return total;
}
Sum(1, 2, 3);        // works
Sum(1, 2, 3, 4, 5);  // also works, any count
 
// ref - passes by REFERENCE, method can modify
// the caller's actual variable
void Double(ref int number)
{
    number = number * 2;
}
int value = 5;
Double(ref value);
Console.WriteLine(value);  // 10 - actually changed
 
// out - similar to ref, but the method MUST assign
// a value before returning, commonly used for
// &quot;try&quot; pattern methods
bool TryParse(string input, out int result)
{
    return int.TryParse(input, out result);
}
 
// Method overloading - same name, different
// parameter signatures
int Multiply(int a, int b) =&amp;gt; a * b;
double Multiply(double a, double b) =&amp;gt; a * b;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: What is the actual difference between ref and out parameters? Write a method using each that demonstrates the difference.&lt;/p&gt;
&lt;h3 id=&quot;topic-13-enums&quot;&gt;Topic 13: Enums&lt;/h3&gt;
&lt;p&gt;An enum defines a named set of related constant values, making code more readable than using raw numbers or strings for a fixed set of options.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public enum OrderStatus
{
    Pending,     // 0 by default
    Processing,  // 1
    Shipped,     // 2
    Delivered    // 3
}
 
var status = OrderStatus.Processing;
Console.WriteLine(status);          // &quot;Processing&quot;
Console.WriteLine((int)status);      // 1
 
// Assigning explicit values
public enum HttpStatusCode
{
    Ok = 200,
    NotFound = 404,
    ServerError = 500
}
 
// Converting from an int
var code = (HttpStatusCode)404;
Console.WriteLine(code);  // NotFound
 
// Parsing from a string
var parsed = Enum.Parse&amp;lt;OrderStatus&amp;gt;(&quot;Shipped&quot;);
 
// Switching on an enum
string GetMessage(OrderStatus status) =&amp;gt; status switch
{
    OrderStatus.Pending =&amp;gt; &quot;Order received&quot;,
    OrderStatus.Shipped =&amp;gt; &quot;On its way&quot;,
    OrderStatus.Delivered =&amp;gt; &quot;Completed&quot;,
    _ =&amp;gt; &quot;Unknown status&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Create an enum for days of the week, and write a method that returns true if a given day is a weekend.&lt;/p&gt;
&lt;h3 id=&quot;topic-14-structs-vs-classes&quot;&gt;Topic 14: Structs vs Classes&lt;/h3&gt;
&lt;p&gt;Both structs and classes group related data and behavior together, but a struct is a value type, copied on assignment, while a class is a reference type, shared on assignment - the same fundamental distinction as value types versus reference types, applied to your own custom types.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// struct - value type, typically small, simple data
public struct Point
{
    public int X;
    public int Y;
}
 
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1;       // COPIES the values
p2.X = 99;
Console.WriteLine(p1.X);  // still 1 - p1 untouched
 
// class - reference type, typically larger objects
// with behavior, identity, and mutable state over time
public class Post
{
    public string Title;
    public int ViewCount;
}
 
var post1 = new Post { Title = &quot;Hello&quot;, ViewCount = 0 };
var post2 = post1;  // SHARES the same object
post2.ViewCount = 5;
Console.WriteLine(post1.ViewCount);  // 5 - same object
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use a struct for small, immutable-feeling data with no real identity - a Point, a Color, a Money amount. Use a class for anything with real identity, larger data, or behavior that changes over the object's lifetime - a Post, a Customer, a Service. Most day-to-day domain modeling uses classes; structs are the exception, reached for deliberately.&lt;/p&gt;
&lt;p&gt;Practice question: Explain why using a struct for a large object with 15 fields would generally be a bad idea, based on what you know about value type copying.&lt;/p&gt;
&lt;h3 id=&quot;topic-15-nullable-types&quot;&gt;Topic 15: Nullable Types&lt;/h3&gt;
&lt;p&gt;Value types - int, bool, DateTime - cannot normally hold null; they always have some default value. Nullable value types, written as int?, opt into allowing null, useful for representing &quot;no value provided&quot; distinctly from an actual zero or false.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;int? age = null;
int regularAge = 0;  // 0 is a real value,
                       // different from &quot;no value&quot;
 
if (age.HasValue)
{
    Console.WriteLine(age.Value);
}
 
// GetValueOrDefault - safe access with a fallback
int actualAge = age.GetValueOrDefault(18);
 
// Null-coalescing works here too
int displayAge = age ?? 18;
 
// Nullable reference types (C# 8+) - a compiler
// feature (not a runtime type change) that WARNS
// when a reference type that should never be null
// might actually be null
string? nullableName = null;   // explicitly allowed
string nonNullableName = &quot;Alex&quot;; // compiler warns if
                                   // this is ever assigned
                                   // null somewhere
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Why might a nullable int? be a better choice than a regular int for a &quot;discount percentage&quot; field on an order, where most orders have no discount at all?&lt;/p&gt;
&lt;h3 id=&quot;topic-16-type-conversion-and-casting&quot;&gt;Topic 16: Type Conversion and Casting&lt;/h3&gt;
&lt;p&gt;Converting a value from one type to another - sometimes safe and implicit, sometimes requiring an explicit cast, and sometimes needing a parsing method entirely.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Implicit conversion - safe, no data loss possible,
// compiler does it automatically
int number = 100;
double asDouble = number;  // int fits safely into double
 
// Explicit conversion (casting) - REQUIRED when data
// loss is possible, forces you to acknowledge the risk
double price = 19.99;
int roundedDown = (int)price;  // 19 - truncates,
                                 // does not round
 
// Parsing strings to numbers
string input = &quot;42&quot;;
int parsed = int.Parse(input);        // throws if invalid
bool success = int.TryParse(input, out int result);
                                        // safe, returns
                                        // false instead of
                                        // throwing
 
// Convert class - handles more conversions, including
// null handling
string text = Convert.ToString(42);
int fromString = Convert.ToInt32(&quot;42&quot;);
 
// as vs cast for reference types
object obj = &quot;hello&quot;;
string s1 = (string)obj;      // throws if obj isn't
                                // actually a string
string s2 = obj as string;    // returns null instead
                                // of throwing if it fails
 
// is keyword with pattern matching
if (obj is string str)
{
    Console.WriteLine(str.Length);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: What is the actual difference between int.Parse() and int.TryParse()? When would you genuinely prefer one over the other?&lt;/p&gt;
&lt;h3 id=&quot;topic-17-basic-exception-handling&quot;&gt;Topic 17: Basic Exception Handling&lt;/h3&gt;
&lt;p&gt;Exceptions represent errors that occur during execution. try/catch lets code handle an error gracefully instead of crashing the entire program.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;try
{
    int result = 10 / int.Parse(&quot;0&quot;);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine($&quot;Cannot divide by zero: {ex.Message}&quot;);
}
catch (FormatException ex)
{
    Console.WriteLine($&quot;Invalid number format: {ex.Message}&quot;);
}
catch (Exception ex)
{
    // Generic catch-all - should be LAST, since more
    // specific catches above it would never be reached
    // if this came first
    Console.WriteLine($&quot;Unexpected error: {ex.Message}&quot;);
}
finally
{
    // ALWAYS runs, whether an exception occurred or not -
    // used for cleanup (closing files, connections, etc.)
    Console.WriteLine(&quot;Cleanup runs here regardless&quot;);
}
 
// Throwing your own exceptions
void ValidateAge(int age)
{
    if (age &amp;lt; 0)
    {
        throw new ArgumentException(&quot;Age cannot be negative&quot;, nameof(age));
    }
}
 
// Custom exception types
public class InsufficientFundsException : Exception
{
    public InsufficientFundsException(string message) : base(message) { }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practice question: Write a method that safely divides two numbers, catching a divide-by-zero error and returning 0 instead of letting the exception propagate.&lt;/p&gt;
&lt;h3 id=&quot;key-lessons-1&quot;&gt;Key Lessons&lt;/h3&gt;
&lt;p&gt;Value types copy on assignment; reference types share the same underlying object - this single distinction explains a huge share of &quot;unexpected&quot; C# behavior.&lt;/p&gt;
&lt;p&gt;Arrays are fixed size; List resizes automatically - default to List unless the size is genuinely fixed and known.&lt;/p&gt;
&lt;p&gt;Dictionary&amp;lt;TKey,TValue&amp;gt; gives fast key-based lookup; HashSet gives fast existence checks with no duplicates.&lt;/p&gt;
&lt;p&gt;Strings are immutable - every apparent modification creates a new string, which is exactly why StringBuilder exists for heavy concatenation.&lt;/p&gt;
&lt;p&gt;ref and out both pass by reference, but out requires the method to assign a value before returning, commonly used in the TryParse pattern.&lt;/p&gt;
&lt;p&gt;Structs and classes mirror the value versus reference type distinction for your own custom types - most everyday modeling uses classes.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's Next&lt;/h3&gt;
&lt;p&gt;Part 2 covers mid-level concepts - LINQ, generics, delegates and events, interfaces versus abstract classes, and exception handling in real depth.&lt;/p&gt;
&lt;h3 id=&quot;summary-1&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;Every C# codebase, no matter how advanced, is built from these same basic pieces - variables and types, strings, collections, control flow, methods, and basic error handling. Interview questions on these topics aren't testing whether you've memorized syntax - they're testing whether the underlying model, value versus reference, mutable versus immutable, fixed versus resizable, is genuinely internalized, since that's what actually predicts whether unfamiliar code will behave the way you expect it to.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Originally published at TechStack Blog: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/post.html?slug=csharp-interview-prep-basics-part1&quot;&gt;https://www.techstackblog.com/post.html?slug=csharp-interview-prep-basics-part1&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;More from TechStack Blog: C# / .NET: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/category.html?cat=csharp&quot;&gt;https://www.techstackblog.com/category.html?cat=csharp&lt;/a&gt;&lt;br&gt;
CS Fundamentals: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.techstackblog.com/category.html?cat=cs-fundamentals&quot;&gt;https://www.techstackblog.com/category.html?cat=cs-fundamentals&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26210/coding-interview-variables-strings-arrays-lists-dictionaries-every-basic-building-block</guid>
<pubDate>Fri, 04 Sep 2026 14:08:32 +0000</pubDate>
</item>
<item>
<title>Twinify 1.0.2: A Look at the Latest Performance Improvements</title>
<link>https://coderlegion.com/25519/twinify-1-0-2-a-look-at-the-latest-performance-improvements</link>
<description>&lt;p&gt;Over the past few weeks, the performance and memory efficiency of &lt;strong&gt;Twinify&lt;/strong&gt; has been improved, twinify is an open-source object-to-object mapping library for .NET.&lt;/p&gt;
&lt;p&gt;The latest release, &lt;strong&gt;Twinify 1.0.2&lt;/strong&gt;, brings some encouraging improvements compared with the initial 1.0.0 release.&lt;/p&gt;
&lt;p&gt;I also wanted to keep the benchmarking honest, so the tests include &lt;strong&gt;AutoMapper 16.2.0&lt;/strong&gt; as a reference point.&lt;/p&gt;
&lt;p&gt;Twinify is designed to make object mapping simple, while supporting common scenarios such as nested objects, collections, flattening, constructor mapping, and custom mappings.&lt;/p&gt;
&lt;h3 id=&quot;the-benchmark&quot;&gt;The Benchmark&lt;/h3&gt;
&lt;p&gt;The benchmark uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;.NET 10&lt;/li&gt;
&lt;li&gt;BenchmarkDotNet 0.15.8&lt;/li&gt;
&lt;li&gt;Windows 11&lt;/li&gt;
&lt;li&gt;Intel Core i7-1355U&lt;/li&gt;
&lt;li&gt;Twinify 1.0.0&lt;/li&gt;
&lt;li&gt;Twinify 1.0.2&lt;/li&gt;
&lt;li&gt;AutoMapper 16.2.0&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The object graph being mapped is not just a simple flat object.&lt;/p&gt;
&lt;p&gt;The benchmark maps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User
 ├── Address
 └── Orders
      ├── Order
      │    └── OrderItems
      └── Order
           └── OrderItems
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two scenarios are tested:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Mapping a single &lt;code&gt;User&lt;/code&gt; to &lt;code&gt;UserDto&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Mapping 1,000 users to &lt;code&gt;UserDto&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Memory allocation and GC activity are also measured using BenchmarkDotNet's &lt;code&gt;MemoryDiagnoser&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;performance-progress&quot;&gt;Performance Progress&lt;/h3&gt;
&lt;p&gt;Here are the results.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario    &lt;/th&gt;&lt;th&gt;Metric    &lt;/th&gt;&lt;th&gt;Twinify 1.0.0 &lt;/th&gt;&lt;th&gt;   Twinify 1.0.2 &lt;/th&gt;&lt;th&gt;AutoMapper 16.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Single User &lt;/td&gt;&lt;td&gt;Mean      &lt;/td&gt;&lt;td&gt;     3.819 μs &lt;/td&gt;&lt;td&gt;        4.087 μs &lt;/td&gt;&lt;td&gt;         1.066 μs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Single User &lt;/td&gt;&lt;td&gt;Allocated &lt;/td&gt;&lt;td&gt;      3,304 B &lt;/td&gt;&lt;td&gt;     &lt;strong&gt;1,848 B&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;            848 B&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1,000 Users &lt;/td&gt;&lt;td&gt;Mean      &lt;/td&gt;&lt;td&gt; 4,420.174 μs &lt;/td&gt;&lt;td&gt;&lt;strong&gt;2,420.444 μs&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;     1,108.267 μs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1,000 Users &lt;/td&gt;&lt;td&gt;Allocated &lt;/td&gt;&lt;td&gt;  3,320,120 B &lt;/td&gt;&lt;td&gt; &lt;strong&gt;1,864,120 B&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;        864,120 B&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1,000 Users &lt;/td&gt;&lt;td&gt;Gen0      &lt;/td&gt;&lt;td&gt;     523.4375 &lt;/td&gt;&lt;td&gt;    &lt;strong&gt;296.8750&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;         136.7188&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1,000 Users &lt;/td&gt;&lt;td&gt;Gen1      &lt;/td&gt;&lt;td&gt;     242.1875 &lt;/td&gt;&lt;td&gt;    &lt;strong&gt;171.8750&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;          78.1250&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;45-faster-bulk-mapping&quot;&gt;45% Faster Bulk Mapping&lt;/h3&gt;
&lt;p&gt;The biggest improvement can be seen when mapping 1,000 users.&lt;/p&gt;
&lt;p&gt;Twinify 1.0.0:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;4,420.174 μs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twinify 1.0.2:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;2,420.444 μs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's approximately a &lt;strong&gt;45% reduction in execution time&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The performance gap with AutoMapper has also become much smaller.&lt;/p&gt;
&lt;p&gt;AutoMapper 16.2.0 completes the same benchmark in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1,108.267 μs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is still a gap, but the improvement from the initial Twinify release is significant.&lt;/p&gt;
&lt;h3 id=&quot;44-less-memory-allocation&quot;&gt;44% Less Memory Allocation&lt;/h3&gt;
&lt;p&gt;Performance isn't only about execution time.&lt;/p&gt;
&lt;p&gt;For the 1,000-user benchmark:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Twinify 1.0.0
3,320,120 B

Twinify 1.0.2
1,864,120 B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's approximately &lt;strong&gt;44% less memory allocation&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This is particularly important for applications performing large numbers of mappings because unnecessary allocations can increase GC pressure.&lt;/p&gt;
&lt;h3 id=&quot;reduced-gc-pressure&quot;&gt;Reduced GC Pressure&lt;/h3&gt;
&lt;p&gt;The reduction in allocations is also reflected in the GC measurements.&lt;/p&gt;
&lt;p&gt;For the 1,000-user benchmark, Gen0 collections dropped from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;523.4375 → 296.8750
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's approximately a &lt;strong&gt;43% reduction&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Gen1 also decreased:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;242.1875 → 171.8750
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or approximately &lt;strong&gt;29%&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;These improvements are encouraging because they show that the memory optimizations are not simply reflected in the allocation column; they are also reducing GC activity.&lt;/p&gt;
&lt;h3 id=&quot;what-about-automapper-&quot;&gt;What About AutoMapper?&lt;/h3&gt;
&lt;p&gt;AutoMapper 16.2.0 is still ahead in this benchmark.&lt;/p&gt;
&lt;p&gt;For 1,000 users:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Twinify 1.0.2   2,420.444 μs
AutoMapper      1,108.267 μs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And in allocations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Twinify 1.0.2   1,864,120 B
AutoMapper        864,120 B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So there is still work to do.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next-&quot;&gt;What's Next?&lt;/h3&gt;
&lt;p&gt;The next optimization targets are clear.&lt;/p&gt;
&lt;p&gt;I'm particularly interested in reducing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mapping execution overhead&lt;/li&gt;
&lt;li&gt;Allocations during nested and collection mapping&lt;/li&gt;
&lt;li&gt;Reflection or dynamic dispatch on the hot path&lt;/li&gt;
&lt;li&gt;Temporary objects and collections&lt;/li&gt;
&lt;li&gt;GC pressure during large mapping operations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The current results already show that there is plenty of room for optimization.&lt;/p&gt;
&lt;p&gt;The next milestone is to continue closing the gap while keeping Twinify's API simple and predictable.&lt;/p&gt;
&lt;h3 id=&quot;why-benchmark-&quot;&gt;Why Benchmark?&lt;/h3&gt;
&lt;p&gt;One of the things I want to emphasize with Twinify is that performance claims should be backed by reproducible benchmarks.&lt;/p&gt;
&lt;p&gt;Rather than simply saying that a mapper is &quot;fast&quot;, I'm trying to track actual changes between releases.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Twinify 1.0.0
      ↓
4.42 ms / 1,000 users
      ↓
Twinify 1.0.2
      ↓
2.42 ms / 1,000 users
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gives future releases something concrete to improve against.&lt;/p&gt;
&lt;h3 id=&quot;try-twinify&quot;&gt;Try Twinify&lt;/h3&gt;
&lt;p&gt;Twinify is available on NuGet and the documentation contains examples covering profiles, nested mappings, collections, dependency injection, and other mapping scenarios.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Documentation:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/StevenKamwaza/Twinify.Docs?utm_source=chatgpt.com&quot;&gt;Twinify Documentation&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NuGet:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Twinify?utm_source=chatgpt.com&quot;&gt;Twinify on NuGet&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The current NuGet listing identifies &lt;strong&gt;1.0.2 as the latest Twinify release&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Twinify 1.0.2 is another step forward.&lt;/p&gt;
&lt;p&gt;The current benchmark shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;45% faster&lt;/strong&gt; bulk mapping compared with Twinify 1.0.0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;44% less memory allocation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;♻️ &lt;strong&gt;43% fewer Gen0 collections&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A significantly smaller performance gap compared with the initial release&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is still a lot of optimization ahead, and that's part of the fun.&lt;/p&gt;
&lt;p&gt;I'm looking forward to seeing how far Twinify can go.&lt;/p&gt;
&lt;p&gt;If you're interested in .NET object mapping, performance optimization, or open-source development, I'd love to hear your thoughts and feedback.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Twinify — Object Mapping Made Simple.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;dotnet-csharp-opensource-performance&quot;&gt;dotnet #csharp #opensource #performance&lt;/h2&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25519/twinify-1-0-2-a-look-at-the-latest-performance-improvements</guid>
<pubDate>Thu, 27 Aug 2026 05:21:17 +0000</pubDate>
</item>
<item>
<title>Getting Started with Twinify: A Free, Open-Source Object Mapper for Modern .NET</title>
<link>https://coderlegion.com/25518/getting-started-with-twinify-a-free-open-source-object-mapper-for-modern-net</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/wc1s88jijq13qdiynpph.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Lightweight. Familiar. MIT Licensed. Built for .NET 8, 9, and 10.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Following AutoMapper's recent licensing changes, many .NET developers have started looking for an open-source alternative that doesn't require rewriting years of mapping code.&lt;/p&gt;
&lt;p&gt;That's one of the reasons I built &lt;strong&gt;Twinify&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Twinify is a lightweight, high-performance object-to-object mapper that provides a familiar fluent API, making it easy to map DTOs, entities, view models, records, and complex object graphs while remaining completely open source under the &lt;strong&gt;MIT License&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;If you're already using AutoMapper, you'll find the transition straightforward for many common mapping scenarios.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-twinify-&quot;&gt;Why Twinify?&lt;/h2&gt;
&lt;p&gt;✔ MIT Licensed&lt;/p&gt;
&lt;p&gt;✔ Built for .NET 8, .NET 9 &amp;amp; .NET 10&lt;/p&gt;
&lt;p&gt;✔ Familiar AutoMapper-style Fluent API&lt;/p&gt;
&lt;p&gt;✔ Dependency Injection Support&lt;/p&gt;
&lt;p&gt;✔ Automatic Convention-Based Mapping&lt;/p&gt;
&lt;p&gt;✔ Nested Objects &amp;amp; Collections&lt;/p&gt;
&lt;p&gt;✔ Flattening&lt;/p&gt;
&lt;p&gt;✔ Record &amp;amp; Constructor Mapping&lt;/p&gt;
&lt;p&gt;✔ Mapping Diagnostics with &lt;code&gt;Explain&amp;lt;TSource, TDestination&amp;gt;()&lt;/code&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;installation-1&quot;&gt;Installation&lt;/h2&gt;
&lt;p&gt;Install the required packages.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package Twinify
dotnet add package Twinify.DependencyInjection
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-1-create-a-mapping-profile&quot;&gt;Step 1 — Create a Mapping Profile&lt;/h2&gt;
&lt;p&gt;Define your mappings by inheriting from &lt;code&gt;MappingProfile&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserProfile : MappingProfile
{
    public UserProfile()
    {
        CreateMap&amp;lt;User, UserDto&amp;gt;()
            .ForMember(d =&amp;gt; d.FullName,
                opt =&amp;gt; opt.MapFrom(s =&amp;gt; $&quot;{s.FirstName} {s.LastName}&quot;))
            .ForMember(d =&amp;gt; d.Password,
                opt =&amp;gt; opt.Ignore());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twinify uses a familiar fluent configuration model that should feel natural to developers coming from AutoMapper.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-2-register-twinify&quot;&gt;Step 2 — Register Twinify&lt;/h2&gt;
&lt;p&gt;Register your mapping profiles during application startup.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddTwinify(cfg =&amp;gt;
{
    cfg.AddProfile&amp;lt;UserProfile&amp;gt;();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;step-3-map-your-objects&quot;&gt;Step 3 — Map Your Objects&lt;/h2&gt;
&lt;p&gt;Inject &lt;code&gt;IMapper&lt;/code&gt; wherever it's needed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserService(IMapper mapper)
{
    public UserDto GetUser(User user)
    {
        return mapper.Map&amp;lt;UserDto&amp;gt;(user);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's all you need.&lt;/p&gt;
&lt;p&gt;Twinify automatically maps properties with matching names while allowing full customization when required.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;
&lt;h3 id=&quot;automatic-convention-mapping&quot;&gt;Automatic Convention Mapping&lt;/h3&gt;
&lt;p&gt;Properties with identical names are mapped automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User.Name
        ↓
UserDto.Name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No configuration required.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;automatic-flattening&quot;&gt;Automatic Flattening&lt;/h3&gt;
&lt;p&gt;Nested properties are flattened automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Source

User.Address.City

↓

Destination

UserDto.AddressCity
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;nested-object-mapping&quot;&gt;Nested Object Mapping&lt;/h3&gt;
&lt;p&gt;Twinify recursively maps nested object graphs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Order
 └── Customer
 └── Address
 └── Contacts

↓

OrderDto
 └── CustomerDto
 └── AddressDto
 └── ContactDtos
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;collection-mapping&quot;&gt;Collection Mapping&lt;/h3&gt;
&lt;p&gt;Supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lists&lt;/li&gt;
&lt;li&gt;Arrays&lt;/li&gt;
&lt;li&gt;Dictionaries&lt;/li&gt;
&lt;li&gt;Nested collections&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;constructor-amp-record-mapping&quot;&gt;Constructor &amp;amp; Record Mapping&lt;/h3&gt;
&lt;p&gt;Twinify automatically constructs destination types, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Immutable objects&lt;/li&gt;
&lt;li&gt;Records&lt;/li&gt;
&lt;li&gt;Parameterized constructors&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fluent-configuration&quot;&gt;Fluent Configuration&lt;/h3&gt;
&lt;p&gt;Twinify supports a rich configuration API, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CreateMap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ForMember&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MapFrom&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Ignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Condition&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ConvertUsing&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BeforeMap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AfterMap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ReverseMap&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IncludeBase&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PreserveReferences&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;explain-your-mappings&quot;&gt;Explain Your Mappings&lt;/h3&gt;
&lt;p&gt;One feature that sets Twinify apart is mapping diagnostics.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;mapper.Explain&amp;lt;User, UserDto&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of guessing why a property mapped the way it did, &lt;code&gt;Explain()&lt;/code&gt; shows exactly where every destination member receives its value, making debugging significantly easier.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;migrating-from-automapper&quot;&gt;Migrating from AutoMapper&lt;/h2&gt;
&lt;p&gt;Many existing AutoMapper projects can migrate with only a few mechanical changes.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;AutoMapper &lt;/th&gt;&lt;th&gt;Twinify&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Profile&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;MappingProfile&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CreateMap()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Same familiar API&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ForMember()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Same familiar API&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MapFrom()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Same familiar API&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ReverseMap()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Supported&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AddAutoMapper()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AddTwinify()&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Custom resolvers &lt;/td&gt;&lt;td&gt;&lt;code&gt;ConvertUsing()&lt;/code&gt; and member configuration&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;migration-guide&quot;&gt;Migration Guide&lt;/h3&gt;
&lt;h4 id=&quot;1-remove-automapper&quot;&gt;1. Remove AutoMapper&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet remove package AutoMapper
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-install-twinify&quot;&gt;2. Install Twinify&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package Twinify
dotnet add package Twinify.DependencyInjection
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-update-your-profiles&quot;&gt;3. Update Your Profiles&lt;/h4&gt;
&lt;p&gt;Replace&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserProfile : Profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public class UserProfile : MappingProfile
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;4-update-dependency-injection&quot;&gt;4. Update Dependency Injection&lt;/h4&gt;
&lt;p&gt;Replace&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddAutoMapper(...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddTwinify(cfg =&amp;gt;
{
    cfg.AddProfile&amp;lt;UserProfile&amp;gt;();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;5-build-amp-test&quot;&gt;5. Build &amp;amp; Test&lt;/h4&gt;
&lt;p&gt;Many existing &lt;code&gt;CreateMap()&lt;/code&gt; and &lt;code&gt;ForMember()&lt;/code&gt; configurations should require little or no modification.&lt;/p&gt;
&lt;p&gt;As with any migration, it's recommended to run your existing mapping tests to validate behavior.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-i-built-twinify&quot;&gt;Why I Built Twinify&lt;/h2&gt;
&lt;p&gt;I wanted a mapper that is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Open source&lt;/li&gt;
&lt;li&gt;Lightweight&lt;/li&gt;
&lt;li&gt;Easy to understand&lt;/li&gt;
&lt;li&gt;Familiar to existing AutoMapper users&lt;/li&gt;
&lt;li&gt;Built specifically for modern .NET&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rather than forcing developers to learn an entirely new API, Twinify focuses on keeping the transition intuitive while continuing to evolve with community feedback.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h2&gt;
&lt;p&gt; NuGet&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package Twinify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Documentation&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/StevenKamwaza/Twinify.Docs&quot;&gt;https://github.com/StevenKamwaza/Twinify.Docs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt; Source Code&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/StevenKamwaza/Twinify&quot;&gt;https://github.com/StevenKamwaza/Twinify&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;⭐ If Twinify helps your project, consider giving the repository a star and sharing your feedback. Community contributions and feature requests are always welcome.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;happy-coding-&quot;&gt;Happy Coding!&lt;/h2&gt;
&lt;p&gt;Built with ❤️ for the .NET community.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;share-your-thoughts&quot;&gt;Share Your Thoughts&lt;/h3&gt;
&lt;p&gt;Have questions, feature ideas, or migration feedback?&lt;/p&gt;
&lt;p&gt;Open an issue or start a discussion on GitHub—I'd love to hear how you're using Twinify in your projects.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;trending-hashtags&quot;&gt;Trending Hashtags&lt;/h2&gt;
&lt;h2 id=&quot;dotnet-csharp-dotnet8-dotnet9-dotnet10-opensource-nuget-softwareengineering-backend-webdevelopment-cleanarchitecture-developer-coding-programming-github-aspnetcore-entityframework-architecture-productivity-developers-devcommunity-100daysofcode-buildinpublic-oss-automapper-twinify&quot;&gt;dotnet #csharp #dotnet8 #dotnet9 #dotnet10 #opensource #nuget #softwareengineering #backend #webdevelopment #cleanarchitecture #developer #coding #programming #github #aspnetcore #entityframework #architecture #productivity #developers #devcommunity #100DaysOfCode #BuildInPublic #OSS #AutoMapper #Twinify&lt;/h2&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25518/getting-started-with-twinify-a-free-open-source-object-mapper-for-modern-net</guid>
<pubDate>Thu, 27 Aug 2026 05:20:33 +0000</pubDate>
</item>
</channel>
</rss>