{
    "archive_path": "archive/1766132739.197973",
    "base_url": "www.seangoedecke.com/invalid-states",
    "basename": "",
    "bookmarked_date": "2025-12-19 08:25",
    "canonical": {
        "archive_org_path": "https://web.archive.org/web/www.seangoedecke.com/invalid-states",
        "dom_path": "output.html",
        "favicon_path": "favicon.ico",
        "git_path": "git/",
        "google_favicon_path": "https://www.google.com/s2/favicons?domain=www.seangoedecke.com",
        "headers_path": "headers.json",
        "htmltotext_path": "htmltotext.txt",
        "index_path": "index.html",
        "media_path": "media/",
        "mercury_path": "mercury/content.html",
        "pdf_path": "output.pdf",
        "readability_path": "readability/content.html",
        "screenshot_path": "screenshot.png",
        "singlefile_path": "singlefile.html",
        "warc_path": "warc/",
        "wget_path": null
    },
    "domain": "www.seangoedecke.com",
    "downloaded_at": "2025-12-19T08:25:41.343043+00:00",
    "downloaded_datestr": "2025-12-19 08:25",
    "extension": "",
    "hash": "WWH7RRMJX23E27P129T7",
    "history": {
        "archive_org": [
            {
                "cmd": [
                    "/usr/bin/curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--proxy",
                    "socks5://tor-socks-proxy:9150",
                    "--head",
                    "--max-time",
                    "60",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
                    "https://web.archive.org/save/https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "8.10.1",
                "end_ts": "2025-12-19T08:27:00.690511+00:00",
                "index_texts": null,
                "output": "TimeoutExpired: Command '['/usr/bin/curl', '--silent', '--location', '--compressed', '--proxy', 'socks5://tor-socks-proxy:9150', '--head', '--max-time', '60', '--user-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)', 'https://web.archive.org/save/https://www.seangoedecke.com/invalid-states/']' timed out after 60 seconds",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:26:00.617202+00:00",
                "status": "failed"
            }
        ],
        "dom": [
            {
                "cmd": [
                    "/usr/bin/chromium-browser",
                    "--proxy-server=socks5://tor-socks-proxy:9150",
                    "--disable-features=DarkMode",
                    "--run-all-compositor-stages-before-draw",
                    "--hide-scrollbars",
                    "--autoplay-policy=no-user-gesture-required",
                    "--no-first-run",
                    "--use-fake-ui-for-media-stream",
                    "--use-fake-device-for-media-stream",
                    "--simulate-outdated-no-au='Tue, 31 Dec 2099 23:59:59 GMT'",
                    "--headless=new",
                    "--no-sandbox",
                    "--no-zygote",
                    "--disable-dev-shm-usage",
                    "--disable-software-rasterizer",
                    "--disable-sync",
                    "--window-size=1440,2000",
                    "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
                    "--user-data-dir=/data/personas/Default/chrome_profile",
                    "--profile-directory=Default",
                    "--dump-dom",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "131.0.6778",
                "end_ts": "2025-12-19T08:25:50.155812+00:00",
                "index_texts": null,
                "output": "output.html",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:45.095452+00:00",
                "status": "succeeded"
            }
        ],
        "favicon": [
            {
                "cmd": [
                    "/usr/bin/curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--proxy",
                    "socks5://tor-socks-proxy:9150",
                    "--max-time",
                    "60",
                    "--output",
                    "favicon.ico",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
                    "https://www.google.com/s2/favicons?domain=www.seangoedecke.com"
                ],
                "cmd_version": "8.10.1",
                "end_ts": "2025-12-19T08:25:44.728691+00:00",
                "index_texts": null,
                "output": "favicon.ico",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:41.500233+00:00",
                "status": "succeeded"
            }
        ],
        "git": [],
        "headers": [
            {
                "cmd": [
                    "/usr/bin/curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--proxy",
                    "socks5://tor-socks-proxy:9150",
                    "--head",
                    "--max-time",
                    "60",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "8.10.1",
                "end_ts": "2025-12-19T08:25:44.904433+00:00",
                "index_texts": null,
                "output": "headers.json",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:44.763147+00:00",
                "status": "succeeded"
            }
        ],
        "htmltotext": [
            {
                "cmd": [
                    "(internal) archivebox.extractors.htmltotext",
                    "./{singlefile,dom}.html"
                ],
                "cmd_version": "0.8.5rc51",
                "end_ts": "2025-12-19T08:25:56.051526+00:00",
                "index_texts": [
                    "(/favicon-32x32.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/manifest.webmanifest) (/icons/icon-48x48.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-72x72.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-96x96.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-144x144.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-192x192.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-256x256.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-384x384.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) (/icons/icon-512x512.png?v=ac7bb3aa286bd21c42741d9c9aa60cb7) 'Make invalid states unrepresentable' considered harmful (seangoedecke.com RSS feed) (/rss.xml) (seangoedecke.com RSS feed) (/feed.xml) (seangoedecke.com RSS feed) (/atom.xml) (/webpack-runtime-8cd3fac48a78d94162cf.js) (/framework-a45383fedbea7a016e3a.js) (/styles-cebf9bedfc7d314ac4fb.js) (/app-7887dc182316466e498e.js) (/commons-728f22ee21186c37c088.js) (/component---src-templates-blog-post-js-f85cbc7039291d572ca7.js) (/page-data/invalid-states/page-data.json) (/page-data/sq/d/1146911855.json) (/page-data/sq/d/3000541721.json) (/page-data/app-data.json) (/page-data/index/page-data.json) (/page-data/tags/software design/page-data.json)  (/) sean goedecke  September 8, 2025\u2502(/tags/software design/) software design   'Make invalid states unrepresentable' considered harmful  One of the most controversial things I believe about good software design is that your code should be more flexible than your domain model . This is in direct opposition to a lot of popular design advice, which is all about binding your code to your domain model as tightly as possible. For instance, a popular principle for good software design is to make invalid states unrepresentable . This usually means doing two things: Enforcing a single source of truth in your database schema. If users and profiles are associated with a user_id on the profiles table, don\u2019t also put a profile_id on the users table, because then you could have a mismatch. Enforcing stricter types. If you use an \u201cpublished/pending\u201d enum to track comment status instead of a string field, you don\u2019t have to worry about weird strings you don\u2019t expect.  I can see why people like this principle. The more you can constrain your software to match your domain model, the easier it will be easier to reason about. However, it\u2019s possible to take it too far. In my view, your software should include as few hard constraints as possible. Real-world software is already subject to the genuinely hard constraints of the real world. If you add further constraints to make your software neater, you risk making it difficult to change when you really, really have to. Because of this, good software design should allow the system to represent some invalid states .  State machines should allow arbitrary state transitions For instance, it\u2019s popular advice to represent many complex software processes as a \u201cstate machine\u201d. Instead of writing ad-hoc code, you can label the various states the system can be in and define a graph of which states can transition to which other states. The edges of that graph become your system\u2019s actions . Here\u2019s an example. If you run an app marketplace, you might thus define a set of states like \u201cdraft\u201d, \u201cpending review\u201d, \u201capproved\u201d, and \u201cpublished\u201d. The actions that connect those states might be \u201csubmit\u201d, \u201capprove\u201d, \u201creject\u201d, \u201cpublish\u201d and \u201chide\u201d. (/static/09ce3a5ecf0860c16fec39889a498207/772aa/mermaid.png)  (mermaid) (mermaid)    Note that you can only submit a draft app, you can only reject a pending app, you can only hide a published app, and so on. These constraints are the entire point of using a state machine. It\u2019s the constraints that make the system much easier to reason about: instead of a ton of app state that could all be modified independently, you have four possible states and five possible actions. The problem, of course, is in the edge cases. What happens when you need to account for \u201cofficial\u201d apps, which are developed internally and shouldn\u2019t go through the normal review process? What happens when a key partner\u2019s app is mistakenly rejected, and the engineering team is asked to \u201cun-reject\u201d it without forcing the partner to resubmit?  What happens when a published app has to be hidden in a way that prevents it from being published again? There are two ways to handle edge cases in a state machine. The first is to update the design. Maybe you can add an \u201cofficial\u201d status that can directly move to \u201cpublished\u201d without review, or a \u201cmanually-approved\u201d action that can take an app straight from \u201cdraft\u201d to \u201capproved\u201d, or a \u201chide-and-reject\u201d action that can take an app from \u201cpublished\u201d back to \u201cdraft\u201d. However, this can dramatically complicate the design: (/static/98850388a09a740397fe744a725059f2/772aa/mermaid-complex.png)  (complex) (complex)    The second way to handle edge cases is to allow arbitrary state transitions. In other words, to relax the constraint that forces state machines to transition only via predefined actions. This keeps the core design simple, at the cost of allowing exceptions. In almost all cases, you should update the design (for instance, any app marketplace needs a \u201chide-and-reject\u201d action handy). But you need to remain flexible enough to allow some arbitrary transitions . Any engineering team that owns a customer-facing service will always be asked to do arbitrary one-off tasks. If you redesign your software each time to allow them, you will end up in a nasty tangle1  . Thus you should ensure that your technical constraints are not absolute. Foreign key constraints Another classic example of this is foreign key constraints. In a relational database, tables are related by primary key (typically ID): a posts table will have a user_id column to show which user owns which post, corresponding to the value of the id column in the users table. When you want to fetch the posts belonging to user 3, you\u2019ll run SQL like SELECT * FROM posts WHERE user_id = 3 . A foreign key constraint forces user_id to correspond to an actual row in the users table. If you try to create or update a post with user_id 999, and there is no user with that id, the foreign key constraint will cause the SQL query to fail. This sounds great, right? A record pointing at a non-existent user is in an invalid state. Shouldn\u2019t we want it to be impossible to represent invalid states? However, many large tech companies - including the two I\u2019ve worked for, GitHub and Zendesk - deliberately choose not to use foreign key constraints. Why not? The main reason is flexibility 2  . In practice, it\u2019s much easier to deal with some illegal states in application logic (like posts with no user attached) than it is to deal with the constraint. With foreign key constraints, you have to delete all related records when a parent record is deleted (edit: I know you can ON DELETE SET NULL as well, but that only works if the field is nullable, which may itself be an invalid state in your domain model). That might be okay for users and posts - though it could become a very expensive operation - but what about relationships that are less solid? If a post has a reviewer_id , what happens when that reviewer\u2019s account is deleted? It doesn\u2019t seem right to delete the post, surely. And so on. If you want to change the database schema, foreign key constraints can be a big problem. Maybe you want to move a table to a different database cluster or shard. If it has any foreign key relationships to other tables, watch out! If you\u2019re not also moving those tables over, you\u2019ll have to remove the foreign key constraint then anyway. Even if you are moving those tables too, it\u2019s a giant hassle to move the data in a way that\u2019s compliant with the constraint, because you can\u2019t just replicate a single table at a time - you have to move the data in chunks that keep the foreign key relationships intact. The principle here is the same as with state machines: at some point you will be forced to do something that violates your tidy constraints , and if you\u2019ve made those constraints truly immovable you\u2019re buying yourself a lot of trouble. Protocol buffers and required fields For a third example, consider (https://protobuf.dev/) Protocol Buffers . Protobufs are Google\u2019s popular open-source serialization format. The first iteration of protobufs allowed you to tag fields as required . If a client parsing a protobuf saw it was missing a required field, that client would reject the message. This sounds sensible enough, right? Many kinds of message don\u2019t make any sense without certain values, so why not encode that constraint into the serialization layer? Isn\u2019t it good to make invalid messages impossible to represent? However, in the second iteration, Google dropped the ability to mark any field as required. This was a controversial decision. In fact, many believe that (https://reasonablypolymorphic.com/blog/protos-are-wrong/) all proto fields should always be required , on the grounds that more constraints make the underlying types more elegant and easier to read about. For the other side of the argument, read (https://news.ycombinator.com/item?id=18190005) this Hacker News comment from a protobuf designer.  In my view, this debate comes down to how seriously you take the problem of changing schemas in a system with multiple consumers . If you want to add a required field to a protobuf, you have to do it like so: Add the required field to every service that creates the protobuf from-scratch Add the required field to any middlemen that are taking the protobuf and passing it on to some other system Add the required field to all other consumers  If you do this out-of-order, messages get dropped on the floor, likely causing some kind of production outage. Removing a required field requires a similar order-dependent process, except in reverse - consumers must drop the field first, followed by middlemen, followed by producers. If you forget to upgrade a consumer service schema (not as unlikely as it sounds, in large companies with thousands of half-forgotten services), the part of it that needs the protobuf will just stop working. When you know all fields are optional, you can change protobuf schemas in a completely order-independent way. All services can upgrade to the new version of the schema more or less at their convenience. The tradeoff is that you won\u2019t have the data until both you and the producer are upgraded to the new schema, so you\u2019ll need to handle that case in your application code. In case you couldn\u2019t tell, I am very much on the Protocol Buffers side of the debate. Having done a lot of schema changes of various kinds, I think it is safer to tolerate incomplete data at the application level during a schema upgrade than be forced to upgrade services in the right order or risk an outage. In other words, I think application code should be willing to tolerate data that violates the domain model . Final thoughts The harder the constraint, the more dangerous it is . When I say that a constraint is hard, I mean that it is very difficult to undo it if you need to. A line of code validating something is a soft constraint, because you can simply remove the line if needed. Something baked into a database schema is a harder constraint, because it requires a migration to change, which (depending on the amount of data and the read volume) can be operationally very difficult. Some constraints are built into the architecture of the entire system: consider the \u201cno data is ever truly deleted\u201d constraint in blockchain or ledger-based systems3  . For most software, domain models are not real . A domain model is only a model of real-world processes. Because of that, the constraints inherent to the domain model (like \u201ctickets must always be marked as completed before being archived\u201d) cannot be truly hard constraints. This is trivially true about most line-of-business or SaaS software, and gets less true the more generic and library-like your software is. If you\u2019re writing a library to do efficient matrix multiplications, you can get away with much harder constraints than if you\u2019re writing directly user-facing code. For much more on this, see my post (/pure-and-impure-engineering) Pure and impure software engineering . I am not arguing that all constraints are bad. Constraints make a system possible to reason about, and the harder the constraint, the better it does its job. A system with no constraints at all (or only very soft constraints) is more of a programming language than a program. I like many kinds of hard constraint: for instance, I prefer protobufs to JSON, I like type signatures, and I strongly prefer relational databases with a set schema to schemaless databases. However, user-facing software will eventaully be forced to break many of its constraints in the interest of better fulfilling the real-world goal of that software . Thus, some invalid states ought to be representable . edit: apologies to my email subscribers, the version of this that went out over email had a typo in the title (it read \u201crepresentable\u201d instead of \u201cunrepresentable\u201d). edit: this post got some comments on (https://news.ycombinator.com/item?id=45164444) Hacker News . I was surprised to see some commenters don\u2019t think that your database schema or your over-the-wire serialization format are a part of how you express your domain model. To me, those things are every bit as relevant as the rest of your code. I like the Fred Brooks quote from Mythical Man Month : \u201cShow me your flowchart and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won\u2019t usually need your flowchart; it\u2019ll be obvious.\u201d edit: this post also got some excellent coments on (https://lobste.rs/s/itj50a/make_invalid_states_unrepresentable) lobste.rs . The other solution some engineers seem to like - refusing to do the task, on the grounds that it\u2019d compromise the software design - is a non-starter, in my opinion. As engineers, it\u2019s our job to support the needs of the business. \u21a9  Foreign key constraints also have performance issues at scale, make database migrations very difficult when you\u2019re touching the foreign key column, and complicate common big-company patterns like soft-deletes. \u21a9  What would it take to allow for true data deletion in a blockchain (for instance, to comply with GDPR)? You would need to change the protocol to something like how Kafka handles true deletion: allowing \u201ctombstone\u201d records to be written into the ledger and then safely compacted away by every node. I leave \u201chow do you safely compact a portion of a Merkle tree in a zero-trust environment\u201d as an exercise for the reader. \u21a9     If you liked this post, consider(https://buttondown.com/seangoedecke) subscribing to email updates about my new posts, or(https://news.ycombinator.com/submitlink?u=https://www.seangoedecke.com/invalid-states/&t='Make invalid states unrepresentable' considered harmful) sharing it on Hacker News .Here's a preview of a related post that shares tags with this one. Do the simplest thing that could possibly work When designing software systems, do the simplest thing that could possibly work. It\u2019s surprising how far you can take this piece of advice. I genuinely think you can do this all the time . You can follow this approach for fixing bugs, for maintaining existing systems, and for architecting new ones. A lot of engineers design by trying to think of the \u201cideal\u201d system: something well-factored, near-infinitely scalable, elegantly distributed, and so on. I think this is entirely the wrong way to go about software design. Instead, spend that time understanding the current system deeply, then do the simplest thing that could possibly work.(/the-simplest-thing-that-could-possibly-work/) Continue reading...    (https://buttondown.com/seangoedecke) subscribe \u2502 (/about) about \u2502 (/podcasts) podcasts \u2502 (/projects) projects \u2502 (/popular) popular \u2502 (/rss.xml) rss \u2502 (https://autodeck.pro) autodeck      Search seangoedecke.com () (Search posts) Search         "
                ],
                "output": "htmltotext.txt",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:56.019978+00:00",
                "status": "succeeded"
            }
        ],
        "media": [
            {
                "cmd": [
                    "/usr/local/bin/yt-dlp",
                    "--restrict-filenames",
                    "--trim-filenames",
                    "128",
                    "--write-description",
                    "--write-info-json",
                    "--write-annotations",
                    "--write-thumbnail",
                    "--no-call-home",
                    "--write-sub",
                    "--write-auto-subs",
                    "--convert-subs=srt",
                    "--yes-playlist",
                    "--continue",
                    "--no-abort-on-error",
                    "--ignore-errors",
                    "--geo-bypass",
                    "--add-metadata",
                    "--format=(bv*+ba/b)[filesize<=750m][filesize_approx<=?750m]/(bv*+ba/b)",
                    "--skip-download",
                    "--cache-dir=/data/yt-dlp-cache/",
                    "--cookies=/data/yt-dlp-cache/cookies.txt",
                    "--proxy=socks5://tor-socks-proxy:9150",
                    "--no-playlist",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "2024.10.7",
                "end_ts": "2025-12-19T08:26:00.589687+00:00",
                "index_texts": [],
                "output": "media/",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:56.369324+00:00",
                "status": "succeeded"
            }
        ],
        "mercury": [
            {
                "cmd": [
                    "/home/archivebox/.npm/bin/postlight-parser",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "2.2.3",
                "end_ts": "2025-12-19T08:25:55.965782+00:00",
                "index_texts": null,
                "output": "mercury/",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:52.727417+00:00",
                "status": "succeeded"
            }
        ],
        "pdf": [],
        "readability": [
            {
                "cmd": [
                    "/home/archivebox/.npm/bin/readability-extractor",
                    "/tmp/tmptevytds3",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "0.0.11",
                "end_ts": "2025-12-19T08:25:52.470691+00:00",
                "index_texts": [
                    "One of the most controversial things I believe about good software design is that your code should be more flexible than your domain model. This is in direct opposition to a lot of popular design advice, which is all about binding your code to your domain model as tightly as possible.\nFor instance, a popular principle for good software design is to make invalid states unrepresentable. This usually means doing two things:\n\nEnforcing a single source of truth in your database schema. If users and profiles are associated with a user_id on the profiles table, don\u2019t also put a profile_id on the users table, because then you could have a mismatch.\nEnforcing stricter types. If you use an \u201cpublished/pending\u201d enum to track comment status instead of a string field, you don\u2019t have to worry about weird strings you don\u2019t expect.\n\nI can see why people like this principle. The more you can constrain your software to match your domain model, the easier it will be easier to reason about. However, it\u2019s possible to take it too far. In my view, your software should include as few hard constraints as possible. Real-world software is already subject to the genuinely hard constraints of the real world. If you add further constraints to make your software neater, you risk making it difficult to change when you really, really have to. Because of this, good software design should allow the system to represent some invalid states. \nState machines should allow arbitrary state transitions\nFor instance, it\u2019s popular advice to represent many complex software processes as a \u201cstate machine\u201d. Instead of writing ad-hoc code, you can label the various states the system can be in and define a graph of which states can transition to which other states. The edges of that graph become your system\u2019s actions.\nHere\u2019s an example. If you run an app marketplace, you might thus define a set of states like \u201cdraft\u201d, \u201cpending review\u201d, \u201capproved\u201d, and \u201cpublished\u201d. The actions that connect those states might be \u201csubmit\u201d, \u201capprove\u201d, \u201creject\u201d, \u201cpublish\u201d and \u201chide\u201d.\n\n      \n    \n  \n  \n    \nNote that you can only submit a draft app, you can only reject a pending app, you can only hide a published app, and so on. These constraints are the entire point of using a state machine. It\u2019s the constraints that make the system much easier to reason about: instead of a ton of app state that could all be modified independently, you have four possible states and five possible actions.\nThe problem, of course, is in the edge cases. What happens when you need to account for \u201cofficial\u201d apps, which are developed internally and shouldn\u2019t go through the normal review process? What happens when a key partner\u2019s app is mistakenly rejected, and the engineering team is asked to \u201cun-reject\u201d it without forcing the partner to resubmit?  What happens when a published app has to be hidden in a way that prevents it from being published again?\nThere are two ways to handle edge cases in a state machine. The first is to update the design. Maybe you can add an \u201cofficial\u201d status that can directly move to \u201cpublished\u201d without review, or a \u201cmanually-approved\u201d action that can take an app straight from \u201cdraft\u201d to \u201capproved\u201d, or a \u201chide-and-reject\u201d action that can take an app from \u201cpublished\u201d back to \u201cdraft\u201d. However, this can dramatically complicate the design:\n\n      \n    \n  \n  \n    \nThe second way to handle edge cases is to allow arbitrary state transitions. In other words, to relax the constraint that forces state machines to transition only via predefined actions. This keeps the core design simple, at the cost of allowing exceptions.\nIn almost all cases, you should update the design (for instance, any app marketplace needs a \u201chide-and-reject\u201d action handy). But you need to remain flexible enough to allow some arbitrary transitions. Any engineering team that owns a customer-facing service will always be asked to do arbitrary one-off tasks. If you redesign your software each time to allow them, you will end up in a nasty tangle1. Thus you should ensure that your technical constraints are not absolute.\nForeign key constraints\nAnother classic example of this is foreign key constraints. In a relational database, tables are related by primary key (typically ID): a posts table will have a user_id column to show which user owns which post, corresponding to the value of the id column in the users table. When you want to fetch the posts belonging to user 3, you\u2019ll run SQL like SELECT * FROM posts WHERE user_id = 3.\nA foreign key constraint forces user_id to correspond to an actual row in the users table. If you try to create or update a post with user_id 999, and there is no user with that id, the foreign key constraint will cause the SQL query to fail.\nThis sounds great, right? A record pointing at a non-existent user is in an invalid state. Shouldn\u2019t we want it to be impossible to represent invalid states? However, many large tech companies - including the two I\u2019ve worked for, GitHub and Zendesk - deliberately choose not to use foreign key constraints. Why not?\nThe main reason is flexibility2. In practice, it\u2019s much easier to deal with some illegal states in application logic (like posts with no user attached) than it is to deal with the constraint. With foreign key constraints, you have to delete all related records when a parent record is deleted (edit: I know you can ON DELETE SET NULL as well, but that only works if the field is nullable, which may itself be an invalid state in your domain model). That might be okay for users and posts - though it could become a very expensive operation - but what about relationships that are less solid? If a post has a reviewer_id, what happens when that reviewer\u2019s account is deleted? It doesn\u2019t seem right to delete the post, surely. And so on.\nIf you want to change the database schema, foreign key constraints can be a big problem. Maybe you want to move a table to a different database cluster or shard. If it has any foreign key relationships to other tables, watch out! If you\u2019re not also moving those tables over, you\u2019ll have to remove the foreign key constraint then anyway. Even if you are moving those tables too, it\u2019s a giant hassle to move the data in a way that\u2019s compliant with the constraint, because you can\u2019t just replicate a single table at a time - you have to move the data in chunks that keep the foreign key relationships intact.\nThe principle here is the same as with state machines: at some point you will be forced to do something that violates your tidy constraints, and if you\u2019ve made those constraints truly immovable you\u2019re buying yourself a lot of trouble.\nProtocol buffers and required fields\nFor a third example, consider Protocol Buffers. Protobufs are Google\u2019s popular open-source serialization format. The first iteration of protobufs allowed you to tag fields as required. If a client parsing a protobuf saw it was missing a required field, that client would reject the message. This sounds sensible enough, right? Many kinds of message don\u2019t make any sense without certain values, so why not encode that constraint into the serialization layer? Isn\u2019t it good to make invalid messages impossible to represent?\nHowever, in the second iteration, Google dropped the ability to mark any field as required. This was a controversial decision. In fact, many believe that all proto fields should always be required, on the grounds that more constraints make the underlying types more elegant and easier to read about. For the other side of the argument, read this Hacker News comment from a protobuf designer. \nIn my view, this debate comes down to how seriously you take the problem of changing schemas in a system with multiple consumers. If you want to add a required field to a protobuf, you have to do it like so:\n\nAdd the required field to every service that creates the protobuf from-scratch\nAdd the required field to any middlemen that are taking the protobuf and passing it on to some other system\nAdd the required field to all other consumers\n\nIf you do this out-of-order, messages get dropped on the floor, likely causing some kind of production outage. Removing a required field requires a similar order-dependent process, except in reverse - consumers must drop the field first, followed by middlemen, followed by producers. If you forget to upgrade a consumer service schema (not as unlikely as it sounds, in large companies with thousands of half-forgotten services), the part of it that needs the protobuf will just stop working.\nWhen you know all fields are optional, you can change protobuf schemas in a completely order-independent way. All services can upgrade to the new version of the schema more or less at their convenience. The tradeoff is that you won\u2019t have the data until both you and the producer are upgraded to the new schema, so you\u2019ll need to handle that case in your application code.\nIn case you couldn\u2019t tell, I am very much on the Protocol Buffers side of the debate. Having done a lot of schema changes of various kinds, I think it is safer to tolerate incomplete data at the application level during a schema upgrade than be forced to upgrade services in the right order or risk an outage. In other words, I think application code should be willing to tolerate data that violates the domain model.\nFinal thoughts\nThe harder the constraint, the more dangerous it is. When I say that a constraint is hard, I mean that it is very difficult to undo it if you need to. A line of code validating something is a soft constraint, because you can simply remove the line if needed. Something baked into a database schema is a harder constraint, because it requires a migration to change, which (depending on the amount of data and the read volume) can be operationally very difficult. Some constraints are built into the architecture of the entire system: consider the \u201cno data is ever truly deleted\u201d constraint in blockchain or ledger-based systems3.\nFor most software, domain models are not real. A domain model is only a model of real-world processes. Because of that, the constraints inherent to the domain model (like \u201ctickets must always be marked as completed before being archived\u201d) cannot be truly hard constraints. This is trivially true about most line-of-business or SaaS software, and gets less true the more generic and library-like your software is. If you\u2019re writing a library to do efficient matrix multiplications, you can get away with much harder constraints than if you\u2019re writing directly user-facing code. For much more on this, see my post Pure and impure software engineering.\nI am not arguing that all constraints are bad. Constraints make a system possible to reason about, and the harder the constraint, the better it does its job. A system with no constraints at all (or only very soft constraints) is more of a programming language than a program. I like many kinds of hard constraint: for instance, I prefer protobufs to JSON, I like type signatures, and I strongly prefer relational databases with a set schema to schemaless databases. However, user-facing software will eventaully be forced to break many of its constraints in the interest of better fulfilling the real-world goal of that software. Thus, some invalid states ought to be representable.\nedit: apologies to my email subscribers, the version of this that went out over email had a typo in the title (it read \u201crepresentable\u201d instead of \u201cunrepresentable\u201d).\nedit: this post got some comments on Hacker News. I was surprised to see some commenters don\u2019t think that your database schema or your over-the-wire serialization format are a part of how you express your domain model. To me, those things are every bit as relevant as the rest of your code. I like the Fred Brooks quote from Mythical Man Month: \u201cShow me your flowchart and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won\u2019t usually need your flowchart; it\u2019ll be obvious.\u201d\nedit: this post also got some excellent coments on lobste.rs.\nIf you liked this post, consider subscribing to email updates about my new posts, or sharing it on Hacker News. Here's a preview of a related post that shares tags with this one.Do the simplest thing that could possibly workWhen designing software systems, do the simplest thing that could possibly work.It\u2019s surprising how far you can take this piece of advice. I genuinely think you can do this all the time. You can follow this approach for fixing bugs, for maintaining existing systems, and for architecting new ones.A lot of engineers design by trying to think of the \u201cideal\u201d system: something well-factored, near-infinitely scalable, elegantly distributed, and so on. I think this is entirely the wrong way to go about software design. Instead, spend that time understanding the current system deeply, then do the simplest thing that could possibly work.Continue reading..."
                ],
                "output": "readability/",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:50.299934+00:00",
                "status": "succeeded"
            }
        ],
        "screenshot": [],
        "singlefile": [],
        "title": [
            {
                "cmd": [
                    "/usr/bin/curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--proxy",
                    "socks5://tor-socks-proxy:9150",
                    "--max-time",
                    "60",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
                    "https://www.seangoedecke.com/invalid-states/"
                ],
                "cmd_version": "8.10.1",
                "end_ts": "2025-12-19T08:25:50.209249+00:00",
                "index_texts": null,
                "output": "'Make invalid states unrepresentable' considered harmful",
                "pwd": "/data/archive/1766132739.197973",
                "schema": "ArchiveResult",
                "start_ts": "2025-12-19T08:25:50.189899+00:00",
                "status": "succeeded"
            }
        ],
        "wget": []
    },
    "icons": null,
    "is_archived": true,
    "is_static": false,
    "latest": {
        "archive_org": "TimeoutExpired: Command '['/usr/bin/curl', '--silent', '--location', '--compressed', '--proxy', 'socks5://tor-socks-proxy:9150', '--head', '--max-time', '60', '--user-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)', 'https://web.archive.org/save/https://www.seangoedecke.com/invalid-states/']' timed out after 60 seconds",
        "dom": "output.html",
        "favicon": "favicon.ico",
        "git": null,
        "media": "media/",
        "pdf": null,
        "screenshot": null,
        "singlefile": null,
        "title": "'Make invalid states unrepresentable' considered harmful",
        "warc": null,
        "wget": null
    },
    "link_dir": "/data/archive/1766132739.197973",
    "newest_archive_date": "2025-12-19T08:26:00.617202+00:00",
    "num_failures": 1,
    "num_outputs": 8,
    "oldest_archive_date": "2025-12-19T08:25:41.500233+00:00",
    "path": "/invalid-states/",
    "schema": "Link",
    "scheme": "https",
    "snapshot_abid": "snp_01KCTVDV4DD08341A601PQMJKY",
    "snapshot_id": "a561d5f2-b122-435a-a84e-87b2ad7a4a7e",
    "sources": [
        "/data/sources/1766132738-import.txt"
    ],
    "tags": null,
    "tags_str": "",
    "timestamp": "1766132739.197973",
    "title": "'Make invalid states unrepresentable' considered harmful",
    "url": "https://www.seangoedecke.com/invalid-states/"
}