Skip to content

Instantly share code, notes, and snippets.

@michaelmr
Created June 11, 2025 16:27
Show Gist options
  • Save michaelmr/af84b4458087b1c856e0a4a75d89080e to your computer and use it in GitHub Desktop.
Save michaelmr/af84b4458087b1c856e0a4a75d89080e to your computer and use it in GitHub Desktop.
system design practices for complementary rules.

Section 1: Core Philosophy - Visibility, Simplicity, and Change

Optimize for Visibility:

    All components and their dependencies must be explicit and easily identifiable at an appropriate level of abstraction.

    State and state changes should be simple to identify and modify.

    Components must be honest about their output; no hidden side effects.

    Isolate and reduce non-deterministic behavior (I/O, mutability) and make it highly visible.

Strive for Simplicity (Less is More):

    Reduce the number of inputs and outputs for any given component.

    Reduce the number of components, subcomponents, and scopes.

    Reduce mutability at all costs. State changes should be localized to a few responsible places.

    Reduce the number of representations for the same data model.

    Share nothing by default; only pass the data that is explicitly needed.

Design For Continuous Change:

    Group components that change together (high cohesion).

    Use Adapters (Language Translators) to decouple components that change independently (loose coupling).

    Most processes should be independent to build and maintain.

    Be prepared to change decisions. The system should be malleable.

Section 2: Architectural Style - Data-Oriented & Functional

Embrace Data-Oriented Architecture & Pipelines:

    Model the system as a pipeline or flow of data transformations. Avoid traditional, stateful OOP where methods hide data.

    Define a Clear Orchestration Layer: This layer composes the high-level data flow, connects the main components, and is the single place where I/O effects are executed.

    Favor Data over Direct Calls for Effects: Instead of components performing I/O directly (e.g., calling a database service), they should return data that describes the intended operation (e.g., [save-user, user-data]). The orchestrator interprets this data and executes the effect. This keeps business logic pure and testable.

    Use Opaque Data in Pipelines: For intermediary components that only route or dispatch data, prefer passing generic data structures (like maps or dictionaries) instead of specific static types. This decouples the pipeline's core from the specific data models, allowing them to evolve independently. The components at the ends of the pipeline are responsible for validation and interpretation.

    Do not share information through global memory (e.g., global variables, mutable singletons, shared tables). Pass data explicitly.

    Use a declarative style for orchestration (e.g., map, filter, reduce) instead of imperative loops.

Implement CQRS and Separate Information from Representation:

    Separate the processes for changing state (Commands) from the processes for reading state (Queries).

    The System of Record should be Normalized: The "write" side should store information as a normalized, canonical log of facts or events. This is the immutable source of truth.

    Provide Declarative Access to Information: The "read" side should provide a flexible, queryable interface that allows consumers (UIs, other services) to declaratively request the specific shape of data they need. This interface queries denormalized "projections" or "views" built from the source of truth, not the source of truth directly.

Section 3: Data Modeling and State Management

Use Immutability Pragmatically for All Models:

    Business and external models must be treated as immutable. This prevents partial or invalid states.

    "Changes" should result in a new instance of the model, not a mutation.

    Where possible, use language features for immutability (e.g., Java/C# records, Kotlin data classes, const in JS/TS). For other cases, adopt libraries (e.g., Immutable.js, Immer) or enforce it by convention.

    This simplifies concurrency as no locks or synchronization are needed.

Handle State Explicitly and Derivatively:

    Localize state changes to a few, well-defined places.

    Represent Current State as a Derivation of History: Instead of storing mutable state, store an immutable log of changes (events, facts, transactions). The current state is then calculated by "folding" or "reducing" this log. This makes state changes transparent, auditable, and reproducible.

    Represent system evolution as an explicit state machine, preferably as a pure function: (currentState, action) -> newState. For more complex scenarios (currentState, actionOrEvents) -> [newState, eventsOrEffects]

    Do not use the classic OOP "State Pattern," as it often hides the state transitions within objects.

Balance Static Types with Runtime Schemas:

    Use Static Types for Core Entities: Leverage your language's type system (classes, records, structs) to define the shape of core business entities. This provides compile-time safety and excellent tooling (IDE autocompletion).

    Use Runtime Schemas for Boundaries and Validation: At the boundaries of your system (APIs, events, database layers), use schema validation libraries (e.g., Zod, Joi, Cerberus, JSON Schema) to validate incoming and outgoing data. This complements static types by enforcing value-level rules (e.g., email must be a valid email format, age must be > 18) that types cannot capture.

Section 4: Component Design and Dependencies

Build Language Translators (Adapters):

    When communicating between systems or complex modules, create a dedicated translation layer. This is a key place to apply runtime schema validation.

    This layer is a single point of change for validation, transformation, and semantic redefinition.

Keep the Dependency Graph Controlled:

    Do not hide I/O components; they should be visible at the highest architectural level.

    Dependencies should converge; avoid complex, sprawling dependency graphs.

    The number of dependencies is directly related to complexity. Always strive to reduce them.

Organize by Process First:

    Structure code by business processes or features, not by technical layers (e.g., avoid top-level controllers, services, repositories folders).

    Processes tend to be cohesive. It is acceptable to have direct dependencies within a single, cohesive process.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment