Instruction file imported from sanchit1591/project-starter (
.cursor/rules/philosophy.mdc). Copyright stays with the author.
Development Philosophy
These principles guide all development in this project. They are inspired by The Pragmatic Programmer and battle-tested practices.
This file contains the overarching design philosophy - the mindset and principles that inform how we think about code and design. For specific actionable rules, see core-rules.mdc. For development methodologies, see development-practices.mdc.
ETC - Easy To Change (The Foundation)
ETC is the value to live by. All design principles fall under the umbrella of Easy To Change.
Change is inevitable. Software must be written to embrace that reality. When faced with any design decision, ask: "Which option makes future changes easier?"
Core Principle
Good design is easier to change than bad design. Use ETC as your guide to choose between paths. Do conscious reinforcement.
When You Don't Have a Clue
Sometimes you won't know the right answer:
- Write easy to replace, decoupled code
- Treat this as developing instincts
- When making changes, revisit relevant ADRs and in-code WHY comments to question past decisions (see
core-rules.mdcfor the actionable process)
Decision Framework
- Prefer explicit over clever
- Prefer simple over complex
- Prefer boring technology over exciting technology (unless there's a compelling reason)
- If a choice is hard to reverse, make it reversible through abstraction
Mental model: You don't need the right answer today. You need the ability to change tomorrow.
DRY - The Evils of Duplication
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
DRY is about the duplication of knowledge and intent, not just code. Change is inevitable and knowledge isn't stable.
The acid test: When some facet of the code changes, do you find yourself making a change in multiple formats in multiple places?
What IS a DRY violation:
- Same business rule expressed in code AND duplicated in documentation
- Database schema that must be manually kept in sync with a data class
- Configuration values hardcoded in multiple places
- The same validation logic for the same concept in different layers
- The same interface knowledge living independently in multiple places
What is NOT a DRY violation:
- Two functions with identical code that validate different concepts (e.g.,
validate_age()andvalidate_quantity()both checking> 0)- These may start identical but will evolve differently as the intent is different
- Code duplication is just a coincidence when the knowledge differs
- Similar-looking code that represents different knowledge and will evolve independently
- Test code that resembles production code (tests are a different kind of knowledge)
When in doubt: Ask "if this knowledge changes, how many places do I need to update?"
Code Duplication
Not all duplication of code is wrong. Sometimes code is duplicated but the intent is different, in which case code duplication is just a coincidence.
Example: Functions to validate age and quantity may both start as simple integer validations, but they'll grow differently as the intent is different.
Documentation
There are two major concepts in documentation:
- The documents we write (READMEs, ADRs, architecture docs, etc.)
- The code comments within the code we write
Neither should emulate code. Documentation emulating code is a violation of DRY.
Documents we write should explain:
- Concepts explored while making decisions
- Technologies chosen and why
- Choices made and trade-offs considered
- Architecture and design decisions
Code comments should explain:
- WHY things are done in a certain way (business intent, design rationale)
- Decisions, trade-offs, and constraints
- Not WHAT things are (don't explain what Lambda is, what IAM is, etc.)
Example - Code Comments:
# Bad - explains what IAM is
# IAM role allows the function to access S3
# Good - explains business intent
# Grant S3 read access to fetch user-uploaded documents for processing
# Required because document processing happens asynchronously after upload
The Cardinal Rule: Never document WHAT the code does - the code shows that. Always document WHY - the intent, the decision, the context.
Guidelines:
- A brief "what" summary is acceptable only for complex/non-obvious code
- No bloat, no over-commenting
- The 3-month rule: Write enough context that you (or anyone) can pick up the code cold after 3 months and understand the intent
- Comments should explain decisions, trade-offs, and constraints - not mechanics
- If you need extensive comments to explain what code does, the code needs refactoring
Data and Meyer's Uniform Access Principle
Uniform Access Principle: All services offered by a module should be available through a uniform notation, which does not betray whether they are implemented through storage or through computation.
Fields that change with other fields must be calculated fields. If due to a change to one field, another field changes in a fixed manner (e.g., a Line class with start, end, and length), then the dependent field must be calculated.
Key point: Whatever style is chosen (getters/setters, properties, methods), it stays consistent within computed and storage fields. The principle is about consistency, not a specific format.
Example - The format doesn't matter, consistency does:
# Option 1: Properties
class Line:
@property
def start(self) -> Point: ...
@property
def end(self) -> Point: ...
@property
def length(self) -> float: ... # Calculated, but same interface
# Option 2: Getters/Setters
class Line:
def get_start(self) -> Point: ...
def get_end(self) -> Point: ...
def get_length(self) -> float: ... # Calculated, but same interface
Key points:
- Never expose data structure differences
- Compute and storage should be exposed alike (same interface style)
- The impact of changes is localized
- The specific format (properties, getters, etc.) is less important than consistency
Integrations
Every integration creates unavoidable duplication: your code must "know" something that already exists elsewhere (API shape, schema, error semantics).
This violates DRY at the surface level — but the goal is not to eliminate duplication, it is to centralize knowledge.
The rule: Never let the same interface knowledge live independently in multiple places.
Internal APIs (Services Within the Same System)
Problem: Two services encode the same contract (request/response shape, fields, meanings). If one evolves, the other silently breaks.
Principle: Define the interface once in a neutral, authoritative form; generate everything else from it.
Why this matters:
- Changes are discovered early (at generation / compile / CI), not at runtime
- The contract becomes the single source of truth
- Clients, docs, mocks, and tests evolve mechanically from the same definition
Mental model: Don't hand-copy the "shape of truth." Make the shape executable and derive everything from it.
Implementation:
- Use OpenAPI/Swagger specs as the source of truth
- Generate client SDKs, server stubs, and documentation from the spec
- Generate test fixtures and mocks from the same spec
External APIs (Services You Don't Control)
Problem: Wrapping an external API by hand still duplicates its contract. When it changes, your wrapper drifts.
Principle: Anchor your integration to a formal contract (spec), not to handwritten assumptions.
Why this matters:
- You localize change: regenerate → see exactly what broke
- You avoid "interpretive maintenance" (re-reading docs, guessing intent)
- The diff is the documentation of what changed
If no spec exists:
- Create a minimal one for the parts you use
- The spec becomes your internal "truth surface" for that dependency
Mental model: You cannot prevent upstream change — but you can make downstream breakage explicit, visible, and safe.
Data Sources (Schemas, CSVs, External Datasets)
Problem: The database already knows what the data is, yet code re-describes it in structs, classes, validations, and business logic.
Principle: Do not re-encode data knowledge in multiple fixed representations. Derive or validate instead.
Two valid strategies:
-
Schema → Code: Generate models from the database/schema so the structure is defined once
- Use tools like SQLAlchemy's
automapor code generators - Schema changes trigger regeneration, not manual updates
- Use tools like SQLAlchemy's
-
Map + Validate (preferred for variable schemas):
- Ingest data into flexible key/value structures
- Enforce correctness via table-driven rules
- Structure lives in schema/config, not in custom code
- Validation expresses what must be true without hard-coding representations
Mental model: Let data shape live in data. Let code enforce meaning, not structure.
Contract Enforcement
- OpenAPI / specs define what an interface is
- Consumer-driven contracts (e.g., Pact) define what a consumer relies on
- Use contract testing to catch breaking changes early
Orthogonality - Designing Independent Systems
Orthogonality is about building systems as independent components that can change independently.
A change in one part should not require changes in unrelated parts. Orthogonality is a form of decoupling, but stronger: not just "loosely coupled," but behaviorally independent.
Why it matters:
- Changes are localized
- Reuse is easier
- Systems are less fragile
- Fewer unintended side effects
Mental model: Orthogonal components don't "know" about each other's internals. They only depend on stable, explicit contracts.
Tradeoff: Upfront Design (Activation Energy)
Orthogonality requires better contracts early:
- You must think about boundaries
- Define inputs/outputs clearly
- Make assumptions explicit
This can feel like friction when starting — but it prevents hidden coupling that becomes expensive later.
Mental model: Pay a small cost in design to avoid large costs in maintenance.
Contracts (Making Dependencies Explicit)
Contracts define:
- What data flows across boundaries
- What invariants must hold
- What errors mean
Goal: Reduce implicit coupling by making expectations visible.
Bad coupling:
- "This field will probably never be null"
- "This value should always be unique"
- "This function won't ever retry"
Good coupling:
- Types + invariants (Pydantic models, type hints)
- Defined error semantics (custom exceptions, Result types)
- Stable interfaces (protocols, abstract base classes)
Mental model: Orthogonality is not "no links." It is "few links, and strong ones."
Don't Rely on What You Don't Control
Avoid depending on properties whose meaning can change externally.
Example: Using phone number as a customer identifier couples your system to:
- Telecom reassignment
- User changes
- Formatting rules
- Country-specific behavior
Better:
- Use a stable internal ID (UUID / surrogate key)
- Treat phone number as an attribute with constraints
Mental model: Identity must be stable; attributes are allowed to change.
Isolating Variation
Separate "what stays the same" from "what changes." Put change behind a seam so you can swap behavior without touching the system.
Example - Strategy Pattern:
Instead of:
if country == "IN":
tax = order.total * 0.18
elif country == "US":
tax = order.total * 0.10
Use:
tax = tax_strategy.calculate(order)
Benefits:
- Caller does not depend on specific rules
- New strategies can be added without modifying existing logic
- Each strategy is testable in isolation
Example - Decorator Pattern:
Decorator adds functionality by wrapping an object without altering its core logic.
Example: Base job → WithLogging → WithPermissions → WithCaching
Benefits:
- Single-responsibility layers
- Features are composable
- No cross-cutting edits across the codebase
Warning: Decorators lose orthogonality when they share:
- Global state
- Hidden ordering assumptions
- Shared mutable data
Mental model: Put change behind a seam. Swap behavior without touching the system.
Global State Violates Orthogonality
Global state creates hidden dependencies and breaks orthogonality.
Problems:
- Action at a distance
- Order-dependent bugs
- Hard-to-test code
- Non-local reasoning
Better:
- Pass Context explicitly
- Inject dependencies
- Centralize environment setup at boundaries
Mental model: If a function's behavior depends on something you can't see in its parameters, it is not orthogonal.
Testing as a Litmus Test
Orthogonal systems:
- Require fewer mocks
- Are easy to test in isolation
- Do not require spinning up unrelated components
Non-orthogonal systems:
- Break tests when unrelated code changes
- Require deep integration setups
- Hide dependencies in globals or side effects
Mental model: If it's hard to test in isolation, it's probably not orthogonal.
Benefits
- Localized change
- Higher reuse
- Reduced fragility
- Fewer cascading failures
- Clearer reasoning about behavior
- More maintainable architecture
Design Checklist
When creating a boundary, ask:
- What is the minimum information crossing this boundary?
- What might change that I want to isolate?
- Can I replace this component with a fake easily?
- Does any hidden global state affect this?
Mental model: Orthogonality is designing for change before change arrives.
Implement Abstractions at Boundaries
- DAO/Repository pattern for database access
- Facades/Clients for external API calls
- Clear interfaces between packages
- Dependency injection over hard-coded dependencies
Signs of Poor Orthogonality
- Changing a database column requires changes in 10 files
- Adding a new external service requires touching core business logic
- You can't test a component without setting up the entire system
Decoupling Strategies
Minimize dependencies between components. Code should be shy - it shouldn't reveal too much or know too much about others.
Strategies:
- Tell, Don't Ask: Tell objects what to do, don't ask for their state and decide for them
- Law of Demeter: Only talk to your immediate friends (avoid
a.b.c.d()chains)- An object should only call methods on itself, its parameters, objects it creates, or its direct component objects
- Event-driven where appropriate: Components emit events, don't call each other directly
- Interface segregation: Depend on narrow interfaces, not fat classes
Package boundaries (example - adapt to your project structure):
- Lower-level packages know nothing about higher-level packages
- Dependencies flow downward (higher-level depends on lower-level)
- Infrastructure can reference configuration but doesn't import business logic
Reversibility - Designing for Change
Reversibility is about making design decisions in a way that they can be undone later.
You build systems such that choosing X today does not permanently lock you into X tomorrow.
Core idea: Make choices that preserve your ability to change them.
Mental model: Every irreversible decision narrows the future. Reversible design keeps options open.
Why Reversibility Matters
Without reversibility:
- Early decisions become structural constraints
- The solution space shrinks over time
- You "dig a hole" that is expensive to climb out of
With reversibility:
- You can replace tools, vendors, and implementations
- You adapt to new requirements without rewrites
- You avoid betting the system on assumptions that may not hold
Mental model: The cost of change should stay bounded as the system grows.
Reversibility, DRY, and Decoupling
Reversibility is not separate from good design — it emerges from it.
DRY:
- Knowledge lives in one authoritative place
- Fewer places must change when a decision is revised
Decoupling / Orthogonality:
- Components depend on contracts, not implementations
- Changing one part does not force changes elsewhere
Together: DRY + Decoupling create the mechanical conditions for reversibility. But reversibility is also a design mindset: you actively choose structures that preserve future options.
Mental model: DRY and decoupling make change possible. Reversibility makes change intentional.
Designing for Reversibility
You design not for the "right" choice today, but for the ability to replace it later.
Examples:
- Abstracting persistence behind DAOs or repositories
- Hiding third-party APIs behind internal interfaces
- Isolating infrastructure behind service boundaries
- Separating policy (what) from mechanism (how)
This does not mean over-engineering. It means isolating what is likely to change from what should remain stable.
Mental model: Freeze decisions only where change would be meaningless or dangerous.
Third-Party Tools and Vendors
Direct dependency:
- Your code embeds vendor-specific models, APIs, or semantics
- Migration becomes a rewrite
Reversible dependency:
- Your system talks to an internal interface
- The vendor is an implementation detail behind an adapter
Result:
- Switching tools becomes localized work
- Vendor lock-in becomes a business choice, not a technical trap
Mental model: Depend on your own abstractions, not on external realities you cannot control.
Databases and Infrastructure
Bad:
- Business logic tightly coupled to a specific database, schema, or query language
Better:
- Persistence behind repositories/DAOs
- Domain logic expressed independently of storage mechanics
- Data shape owned by the domain, not by the database engine
This does not mean pretending databases are identical. It means preventing their differences from infecting your core logic.
Mental model: Infrastructure is a detail. Your model is the truth.
Reversibility vs "Perfect Design"
Reversibility is not about guessing the future. You cannot design for everything.
Instead:
- Make decisions that are cheap to undo
- Delay committing to irreversible structures until necessary
- Keep seams where uncertainty exists
Mental model: You don't need the right answer today. You need the ability to change tomorrow.
Testing as a Signal
Highly reversible systems:
- Are easy to replace parts of in tests
- Allow fakes and stubs without invasive rewrites
- Expose clear boundaries
Low reversibility:
- Tests break when implementations change
- Mocks require knowledge of internals
- Dependencies are hard-wired
Mental model: If replacing a component breaks half your tests, your design is not reversible.
Benefits
- Reduced long-term risk
- Easier migrations and refactors
- Lower cost of architectural mistakes
- Greater strategic freedom
- Systems that age gracefully instead of calcifying
Design Checklist
When making a decision:
- Is this choice easy or hard to reverse later?
- Am I coupling business logic to a tool, vendor, or format?
- Can I replace this component without touching unrelated code?
- Where is uncertainty highest — and have I kept flexibility there?
Mental model: Build for today, but never trap tomorrow.
Design by Contract
Functions and modules should have clear contracts: preconditions, postconditions, and invariants.
Preconditions
What must be true before calling this function? (Caller's responsibility)
Postconditions
What will be true after the function completes? (Function's guarantee)
Invariants
What must always be true about the system state?
In Practice
- Validate inputs at system boundaries (API endpoints, CLI args, external data)
- Use type hints as lightweight contracts
- Use Pydantic models to enforce data shape contracts
- Fail fast and loud when contracts are violated - don't silently continue with bad state
Fail Fast
Errors should be detected as early as possible and reported clearly.
Philosophy: It's better to fail immediately with a clear error than to continue with bad state that causes mysterious bugs later.
- Validate at boundaries, not deep in the call stack
- Don't catch exceptions just to log and re-raise (unless adding context)
- Don't return
Noneto indicate errors - raise exceptions or use explicit Result types - Crash early in development; handle gracefully in production (with proper observability)
Mental model: Fail loudly and clearly, as close to the source of the problem as possible.