Relationship-Driven System Design: A Practical Methodology Checklist

    This article translates five principles distilled from structuralism into concrete software engineering practices: relationship first, opposition-based state modeling, minimal domain-event units, transformation conservation, and whole-system thinking. It shows how these principles apply to domain modeling, state management, event design, data masking and migration, and dependency analysis, and closes with a checklist for reviewing whether a design holds up. The goal is not a fixed template but a set of questions that lead to clearer, more maintainable, and more verifiable system structures.

    1. Introduction: Why System Design Should Start with Relationship Modeling

    System design, relationship modeling, domain modeling, architecture review, and data governance often circle the same problem: the meaning of a module, a table, or a service is not determined by itself, but by its position within the overall structure.

    Structuralism holds a core idea: the meaning of an element comes from its relationships with other elements. Kinship terms, for example, only make sense because they sit within an entire network of kinship relations; a myth may look complicated, but it is usually composed of a handful of opposing relations and basic units.

    Translating this idea into software development yields five practices:

    • Relationship first: model the relationships between entities before defining fields and implementation.
    • Opposition-based combination: express state with a finite, closed set of categories to block illegal states.
    • Minimal unit: break complex flows into domain events with a single, well-defined meaning.
    • Transformation conservation: when the structure changes, make explicit which invariants must hold.
    • Whole first: evaluate the impact of a change from a global view of the dependency network.

    These practices do not replace any specific technology. They help developers take fewer wrong turns when doing domain modeling, architecture review, data migration, and observability work.

    2. Relationship First: How Do You Define Relationships Before Fields?

    1. A Common Pitfall: Staring at Fields, Ignoring Relationships

    When designing a user table, focusing only on fields tends to squeeze relationships that should be modeled independently into text fields. Consider a comma-separated string of user tags:

    1. VIP,dormant,high_value

    On the surface this looks fine, but the relationships are buried inside a string:

    • It is inconvenient to query “which users are both VIP and dormant.”
    • Counting how many users fall under a given tag is unreliable.
    • Renaming a tag may require rewriting historical strings in bulk.

    The root cause is that relationships are a primary component of structure, yet they were stored as ordinary content.

    2. The Practice: Make Relationships First-Class Citizens

    A safer approach is to model each kind of relationship as its own structure: a relationship table, an association table, or an edge in a graph model.

    1. -- Tag definitions: centralized, a closed set of allowed values
    2. CREATE TABLE tag (
    3. tag_code VARCHAR(32) PRIMARY KEY, -- e.g., VIP, DORMANT, HIGH_VALUE
    4. tag_name VARCHAR(64) NOT NULL
    5. );
    6. -- User-tag relationship: many-to-many, the relationship itself becomes a first-class structure
    7. CREATE TABLE user_tag (
    8. user_id BIGINT NOT NULL,
    9. tag_code VARCHAR(32) NOT NULL,
    10. created_at TIMESTAMP NOT NULL,
    11. PRIMARY KEY (user_id, tag_code)
    12. );

    Once the structure is clear, queries and constraints become more direct:

    1. -- Users who have both the VIP and DORMANT tags
    2. SELECT user_id
    3. FROM user_tag
    4. WHERE tag_code IN ('VIP', 'DORMANT')
    5. GROUP BY user_id
    6. HAVING COUNT(DISTINCT tag_code) = 2;
    7. -- Number of users under a specific tag
    8. SELECT COUNT(*) FROM user_tag WHERE tag_code = 'DORMANT';

    3. Implementation Guidance

    • In the first modeling step, draw a relationship diagram: entities are nodes, interactions are edges, and annotate cardinality and direction.
    • Distinguish essential relationships from incidental ones: relationships that are stable over time and need to be queried or constrained should be modeled explicitly.
    • Keep constraints as close to the data as possible: enforce uniqueness, foreign keys, and similar rules in the database where you can.
    • Watch for structure buried in fields: comma-separated strings or concatenated JSON are suitable for storing details that are naturally aggregated, but not for holding relationships that need to be searched or joined.

    3. Opposition-Based Combination: How Do You Block Illegal States?

    1. Express State with a Closed Set of Categories

    Structuralism often organizes deep structure through binary or multi-way oppositions. In engineering, the equivalent is expressing state with a finite, closed set of categories and making the allowed transitions explicit.

    The counterexample is stitching state together from multiple boolean flags:

    1. class Payment: # illustrative code
    2. paid = False # whether paid
    3. refunded = False # whether refunded
    4. canceled = False # whether canceled

    Three booleans can theoretically produce many combinations, but most of them have no business meaning. The more flags you add, the more illegal combinations appear, and the more defensive checks downstream code has to write.

    A safer approach is to express state with a closed enumeration and define the allowed transitions with a transition table.

    1. from enum import Enum
    2. class OrderState(Enum):
    3. CREATED = "CREATED" # created
    4. PAID = "PAID" # paid
    5. SHIPPED = "SHIPPED" # shipped
    6. COMPLETED = "COMPLETED" # completed
    7. CANCELLED = "CANCELLED" # cancelled
    8. TRANSITIONS = {
    9. OrderState.CREATED: {OrderState.PAID, OrderState.CANCELLED},
    10. OrderState.PAID: {OrderState.SHIPPED},
    11. OrderState.SHIPPED: {OrderState.COMPLETED},
    12. OrderState.CANCELLED: set(),
    13. OrderState.COMPLETED: set(),
    14. }
    15. def transit(current: OrderState, target: OrderState) -> OrderState:
    16. if target not in TRANSITIONS[current]:
    17. raise ValueError(f"Illegal state transition: {current.value} -> {target.value}")
    18. return target

    Two points are worth noting:

    1. The transition table should cover every state, with terminal states written explicitly as empty sets.
    2. This interception mainly happens at the runtime entry point and the persistence boundary, not at the type level where states can be fully prevented from being constructed; the goal is to make illegal transitions hard to construct, propagate, and persist.

    2. Be Careful When Introducing Intermediate States

    Intermediate states are valuable in engineering too. Suppose a system originally distinguished only “success/failure,” and later added “processing,” “unknown,” and “pending compensation”; only then did retry policies, alerting rules, and reconciliation logic have an accurate place to attach to.

    When extending, keep in mind:

    • An enumeration is a closed set; adding a value means every consumer must handle it in sync.
    • When transmitting across systems, the receiver should keep a branch for handling unknown values, so parsing does not fail when the other side adds a new value.

    4. Minimal Unit: How Do You Reconstruct Complex Flows with Domain Events?

    1. From Result States to Process Facts

    Many systems record only the final result, such as “order completed.” But a single result may be the accumulation of several facts: the user placed the order, inventory was locked, payment succeeded, the warehouse shipped it, and logistics confirmed delivery.

    If only the final result is recorded, the system loses the ability to reconstruct the process:

    • When a user complains that “the money was charged but the goods never shipped,” it is hard to tell which step got stuck.
    • Statistics on the average time from order to payment lack intermediate timestamps.
    • After a payment channel upgrade, historical data is hard to trace back and align to a consistent definition.

    2. Events Are the Minimal Semantic Unit

    Each fact with independent business meaning can be recorded as one immutable event, following these principles:

    • Single fact: one event describes only one fact.
    • Immutable: once written, an event is not modified; corrections are made by appending compensating events.
    • Traceable: it carries a unique identifier, an occurrence time, and a trace ID.
    • Idempotent: consumers deduplicate by the event’s unique identifier to avoid duplicate processing from repeated delivery.
    1. import uuid
    2. from datetime import datetime, timezone
    3. def emit(producer, event_type: str, order_id: str, payload: dict) -> None:
    4. event = {
    5. "event_id": str(uuid.uuid4()),
    6. "event_type": event_type,
    7. "order_id": order_id,
    8. "occurred_at": datetime.now(timezone.utc).isoformat(),
    9. "trace_id": get_current_trace_id(), # implementation omitted: get trace ID from current context
    10. "payload": payload,
    11. }
    12. producer.send(event)

    This turns the system from “knowing only what things look like now” into “knowing what happened.” When investigating anomalies, you can pull the event stream by order_id; when measuring latency, you can compute stage intervals from event times; and when logic changes, you can re-consume historical events.

    3. The Boundaries of Event Modeling

    Event modeling is not “the finer the better”:

    • Event granularity should align with the business problem; do not force every page click into the model.
    • Event streams generally suit eventual-consistency scenarios; strong-consistency scenarios like funds and inventory still need transactions or compensation mechanisms.
    • As events accumulate, they need tiered retention: core audit events kept long-term, high-frequency debugging events kept for a shorter period.

    5. Transformation Conservation: Which Invariants Must Hold When the Structure Changes?

    1. Structured Extraction: Preserve Field Meaning

    When you need to extract structured information from free-form text, you cannot rely on surface-level ordering; you should define a stable structure.

    The counterexample: parsing fields by the order in which they appear.

    1. import re
    2. PATTERN = re.compile(r"[A-Za-z]-[0-9]-[0-9]+")
    3. FIELDS = ["order_no", "coupon_code", "operator_id"]
    4. def parse(text: str, mapping: dict) -> dict:
    5. values = PATTERN.findall(text)
    6. if len(values) != len(FIELDS):
    7. raise ValueError("Field count does not match match count")
    8. return dict(zip(FIELDS, values))

    The problem with this approach is that once the text template changes and the order shifts, the same text may be parsed into different field meanings.

    A safer approach is to use a stable “field name = value” structure:

    1. import re
    2. ITEM_PATTERN = re.compile(r"([a-z_]+)=([^\s]+)")
    3. def parse_items(text: str, required: list) -> dict:
    4. items = dict(ITEM_PATTERN.findall(text))
    5. missing = [k for k in required if k not in items]
    6. if missing:
    7. raise ValueError(f"Missing required fields: {missing}")
    8. return items
    9. def rebuild_items(items: dict) -> str:
    10. return " ".join(f"{k}={v}" for k, v in items.items())

    As long as the mapping between field names and their meanings stays the same, changes in text order will not break the parsing result. Keeping the structure stable across a transformation is what real compatibility means.

    2. Masking Transformations: Allow Information to Be Lost

    Other scenarios are the opposite: the information is meant to be lost. Personal information such as phone numbers and ID numbers in logs and monitoring, for example, generally should not be stored in plaintext.

    1. import hashlib
    2. import hmac
    3. def mask_phone(phone: str) -> str:
    4. if len(phone) < 7:
    5. raise ValueError("Length too short to mask")
    6. return phone[:3] + "*" * (len(phone) - 7) + phone[-4:]
    7. def phone_digest(phone: str, key: str) -> str:
    8. return hmac.new(key.encode("utf-8"), phone.encode("utf-8"), hashlib.sha256).hexdigest()

    In data governance, you need to distinguish two kinds of requirements:

    • Scenarios that need to be recoverable: for example, identity verification by customer support, which should use a standard symmetric encryption algorithm, with keys managed by dedicated key-management infrastructure and rotated periodically.
    • Scenarios that only need comparison or deduplication: use a keyed digest or a slow-hash scheme, to avoid low-entropy data being recovered by simple enumeration.

    Even with a digest, low-entropy data such as phone numbers can be brute-force enumerated if the key is leaked, so it should be paired with access control, key rotation, and log-masking policies.

    3. Data Migration Also Requires Structural Conservation

    For online data migration, first map the old and new structures to a common intermediate representation, then convert to the target format, and write a validation rule for each kind of transformation:

    • Count validation: the total number of records is the same before and after migration.
    • Sampled comparison: compare key fields record by record, such as amounts precise to the cent.
    • Aggregate reconciliation: reconcile summary metrics such as sums.
    • Rollback capability: keep a snapshot of the original data before migration, run scripts in batches, and support resuming from a checkpoint.
    • Gradual rollout: run the new structure with dual writes or dual reads in parallel, and switch over only after consistency is confirmed.

    Every transformation should be able to answer three questions: which information must be preserved? which information may be lost? and how do you roll back on error?

    6. Whole First: How Do You Evaluate the Impact Radius of a Change?

    1. A Local Optimum Is Not a Global Optimum

    A single module being cleanly designed does not mean the overall structure is sound. Common problems in review include a foundational module being depended on directly by too many upper-level modules, or an implicit circular dependency appearing between two modules.

    What you need is a computable dependency graph: nodes are modules, libraries, or services, and edges represent the direction of dependency. With a dependency graph you can analyze:

    • Circular dependencies: A depends on B and B depends on A, which can make it impossible to arrange build, release, or runtime order.
    • Impact radius: how many modules depend on a given module directly or indirectly.
    • Change propagation: after modifying a shared method, how large is the range that needs regression testing.

    2. How Do You Use Topological Sort to Detect Dependency Cycles?

    Topological sort is a general technique for dependency analysis: if the graph contains a cycle, no complete valid topological order exists.

    1. from collections import deque
    2. def topo_sort(graph: dict) -> tuple:
    3. """graph: {module: set of modules it directly depends on}
    4. Returns (build order, set of nodes that could not be dequeued)
    5. """
    6. indegree = {node: 0 for node in graph}
    7. dependents = {node: set() for node in graph}
    8. for node, deps in graph.items():
    9. for dep in deps:
    10. if dep not in graph:
    11. raise KeyError(f"Unknown dependency: {dep}")
    12. dependents[dep].add(node)
    13. indegree[node] += 1
    14. queue = deque(sorted(n for n, d in indegree.items() if d == 0))
    15. order = []
    16. while queue:
    17. node = queue.popleft()
    18. order.append(node)
    19. for dependent in sorted(dependents[node]):
    20. indegree[dependent] -= 1
    21. if indegree[dependent] == 0:
    22. queue.append(dependent)
    23. unresolved = sorted(n for n, d in indegree.items() if d > 0)
    24. return order, unresolved

    Example:

    1. graph = {
    2. "core-util": set(),
    3. "auth-service": {"core-util"},
    4. "payment-service": {"core-util"},
    5. "order-service": {"auth-service", "payment-service"},
    6. "user-service": {"notify-service"},
    7. "notify-service": {"user-service"},
    8. }
    9. order, unresolved = topo_sort(graph)
    10. print(order)
    11. # ['core-util', 'auth-service', 'payment-service', 'order-service']
    12. print(unresolved)
    13. # ['notify-service', 'user-service']

    Here unresolved represents the nodes that could not be topologically ordered. If a normal node depends on a node in the cycle, it too will end up in unresolved because its in-degree can never reach zero. So if the dependency graph is complex, topological sort alone may not pinpoint the cycle members precisely; in that case you can continue with strongly-connected-component algorithms such as Tarjan’s or Kosaraju’s.

    3. Continuous Governance Points

    • The dependency graph should be generated automatically from build artifacts and checked for newly introduced cycles at every merge.
    • Set dependency-layering rules: low-level shared libraries must not depend back on upper-level business modules.
    • Watch fan-in and fan-out: high fan-in means a large impact surface, high fan-out means heavy coupling.
    • Look at dependencies before splitting a service: following the call chain to observe data flow and change frequency reveals sensible boundaries better than looking at lines of code alone.

    7. Boundary Awareness: What Is Needed Beyond the Relationship Model?

    Structuralism is good at explaining how a system is organized, but it should not be deified. The relationship model has clear boundaries:

    • A relationship graph is good at answering “how is the structure organized,” such as dependencies, entity relationships, and state transitions.
    • “How does the system evolve” needs time series, such as capacity trends, failure frequency, and version changes.
    • “Why did it become what it is today” needs a human perspective, such as team structure, communication paths, and historical decisions.

    A mature approach layers three perspectives together:

    • The relationship perspective constrains the current structure.
    • The time perspective reveals structural drift.
    • The human perspective explains why the structure took shape.

    8. Implementation Checklist: How Do You Check Whether a Design Is Sound?

    Method Key question Directly applicable practice
    Relationship first Which relationships need to be queried and constrained? Model relationships as independent tables or structures; push constraints down to the database
    Opposition-based combination Are there entry points for illegal states? Closed enumeration plus a complete transition table; intercept illegal transitions at the entry point
    Minimal unit Can you reconstruct the process behind every result? Immutable, idempotent, traceable domain events
    Transformation conservation Which information must be preserved, and which may be dropped? Define invariants and a rollback plan before migrating; mask as needed
    Whole first How large is the impact radius of the change? Bring the dependency graph into CI; zero tolerance for cycles
    Boundary awareness Which perspectives beyond structure are needed? Combine the relationship graph, time-series data, and human review

    9. Summary: Improving System Maintainability with a Relationship Perspective

    The greatest insight relationship modeling offers engineers is not a fixed template, but a set of questions:

    • Instead of only asking “what does this module do,” ask “where does it sit in the whole system.”
    • Instead of only asking “what fields are there,” ask “what are the relationships between the fields.”
    • Instead of only asking “what is the current state,” ask “which changes is it allowed to undergo.”
    • Instead of only asking “how many lines of code changed,” ask “which invariants in the structure must be preserved.”

    Once these judgment criteria are part of daily development—whether relationships are explicit, whether state is closed, whether events are minimal, whether transformations are conserved, and whether the whole is under control—the system structure becomes clearer and easier to keep understandable, maintainable, and verifiable through continuous evolution.