Skip to content

What Are the Core Architecture Principles Every Junior Developer Should Know?

When I started my first job as a junior developer, I kept running into the same confusing situations. I’d fix a bug in one module, and something would break somewhere else. I’d write tests that required pulling in half the codebase. I’d spend hours debugging race conditions that seemed impossible to reproduce.

What frustrated me most was not understanding how everything connected together. I knew how to write functions and classes, but the “big picture” of system design felt like a black box.

Then I stumbled upon a Reddit thread where experienced architects shared what they called the “two golden rules” of architecture. These two principles changed how I think about software design.

The Two Rules That Actually Help

Here they are, stripped of all the fancy terminology:

  1. Data should have one and only one writer
  2. Dependencies should point from less stable to more stable

That’s it. Two rules. But they solve about 80% of the architecture problems I encounter.

Let me show you why they work and how to apply them.

Rule #1: The Single Writer Principle

The Problem I Kept Hitting

I was building a simple banking system. Multiple parts of the code needed to modify account balances. Something like this:

bad_multiple_writers.py
class BankAccount:
def __init__(self, balance=0):
self.balance = 0
class ATM:
def withdraw(self, account, amount):
if account.balance >= amount:
account.balance -= amount # Multiple writers!
class OnlineBanking:
def withdraw(self, account, amount):
if account.balance >= amount:
account.balance -= amount # Race condition waiting to happen

Everything seemed fine in testing. But in production? Random negative balances. Missing transactions. Customers getting angry.

I tried adding locks. I tried making things synchronized. The code got messier, but the bugs didn’t go away.

The Question That Changed Everything

Then I read this advice from an experienced architect: “Can I get rid of this race condition by serializing the data mutations with a single writer?”

That question made me realize I was solving the wrong problem. I wasn’t fighting concurrency—I was fighting my own design.

The Solution: One Writer to Rule Them All

good_single_writer.py
class AccountWriter:
"""Single writer - only this class modifies account balance"""
def __init__(self):
self._pending_withdrawals = []
def request_withdrawal(self, account, amount):
# Queue it up, don't modify directly
self._pending_withdrawals.append((account, amount))
def process_pending(self):
# All modifications happen here, one at a time
for account, amount in self._pending_withdrawals:
if account.balance >= amount:
account.balance -= amount
return True
return False
self._pending_withdrawals.clear()
class BankAccount:
def __init__(self, balance=0, writer=None):
self.balance = balance
self._writer = writer or AccountWriter()
def withdraw(self, amount):
# Delegates to single writer - no direct modification
return self._writer.request_withdrawal(self, amount)
class ATM:
def withdraw(self, account, amount):
return account.withdraw(amount) # Goes through single writer
class OnlineBanking:
def withdraw(self, account, amount):
return account.withdraw(amount) # Goes through single writer

Now there’s only one place where balances change. Race conditions? Gone. Debugging? Easy—I know exactly where to look when balances are wrong.

Why This Works

When data has multiple writers, you’re always fighting:

  • Race conditions: Two writers trying to modify at the same time
  • Debugging nightmares: Who changed this value? Good luck finding out
  • Unpredictable state: The system behaves differently depending on timing

When data has one writer:

  • No race conditions: Only one thing modifies the data
  • Clear ownership: You always know who’s responsible
  • Predictable flow: Data mutations follow a single path

Rule #2: Dependencies Point to Stability

The Tight Coupling Problem

After fixing the banking system, I hit another wall. My UserService was directly using PostgreSQLDatabase. When I wanted to switch to MySQL for testing, I had to rewrite half my code.

bad_tight_coupling.py
class UserService:
def __init__(self):
self.db = PostgreSQLDatabase() # Tight coupling!
def get_user(self, user_id):
return self.db.query(f"SELECT * FROM users WHERE id={user_id}")
class PostgreSQLDatabase:
def query(self, sql):
# PostgreSQL-specific implementation
pass

Testing this was a nightmare. I couldn’t test UserService without spinning up a real PostgreSQL instance. And if I wanted to change databases? Rewrite everything.

The Question That Unlocked It

Another piece of advice from that Reddit thread: “If I insert a stable interface between these two dynamic modules, will that help me test both of them independently?”

This was the dependency inversion principle in action, but explained in a way that actually made sense.

The Solution: Depend on Stable Abstractions

good_stable_dependencies.py
from abc import ABC, abstractmethod
# STABLE: This interface rarely changes
class DatabaseInterface(ABC):
@abstractmethod
def get_user(self, user_id):
pass
# UNSTABLE: Implementation can change anytime
class PostgreSQLDatabase(DatabaseInterface):
def get_user(self, user_id):
# PostgreSQL-specific code here
pass
class MySQLDatabase(DatabaseInterface):
def get_user(self, user_id):
# MySQL-specific code here
pass
# UNSTABLE: UserService changes, but depends on stable interface
class UserService:
def __init__(self, db: DatabaseInterface): # Depends on stable abstraction
self.db = db
def get_user(self, user_id):
return self.db.get_user(user_id)
# Testing is now easy
def test_user_service():
mock_db = MockDatabase() # Fake implementation for testing
service = UserService(mock_db)
# Test UserService without touching any real database

Now the dependency arrow points from unstable (UserService) to stable (DatabaseInterface). I can swap implementations without touching UserService. I can test UserService with a mock. I can even have different modules use different databases.

Visualizing the Change

before_coupling.txt
BEFORE: Unstable depending on unstable
┌──────────┐ ┌──────────┐
│UserService│────▶│PostgreSQL │
│(changes │ │(changes │
│frequently)│ │frequently)│
└──────────┘ └──────────┘
Result: Fragile, hard to test, hard to change
after_stable_interface.txt
AFTER: Unstable depending on stable
┌──────────┐ ┌──────────┐ ┌──────────┐
│UserService│────▶│Database │◀────│PostgreSQL │
│(unstable) │ │Interface │ │(unstable) │
│ │ │(STABLE) │ │ │
└──────────┘ └──────────┘ └──────────┘
Result: Testable, flexible, maintainable

The key insight: stable doesn’t mean “never changes.” It means “changes less frequently.” Interfaces change less than implementations. Abstract concepts change less than concrete details.

Putting Both Rules Together

Here’s a pattern I now use in almost every project:

architecture_pattern.py
from abc import ABC, abstractmethod
from queue import Queue
from threading import Thread
# STABLE INTERFACE (rarely changes)
class DataStore(ABC):
@abstractmethod
def save(self, data):
pass
# UNSTABLE IMPLEMENTATION (can change anytime)
class FileDataStore(DataStore):
def save(self, data):
# File-specific implementation
pass
class DatabaseDataStore(DataStore):
def save(self, data):
# Database-specific implementation
pass
# SINGLE WRITER (only one place that calls save())
class DataWriter:
def __init__(self, store: DataStore): # Depends on stable interface
self._store = store
self._queue = Queue()
def write(self, data):
# All data mutations queued here
self._queue.put(data)
def process(self):
# Single point where data actually gets saved
while True:
data = self._queue.get()
self._store.save(data)
# UNSTABLE CLIENTS (don't mutate data directly)
class AnalyticsModule:
def __init__(self, writer: DataWriter): # Depends on stable writer
self._writer = writer
def track_event(self, event):
self._writer.write(event) # Doesn't touch data directly
class UserModule:
def __init__(self, writer: DataWriter): # Depends on stable writer
self._writer = writer
def update_profile(self, user_data):
self._writer.write(user_data) # Doesn't touch data directly

Benefits I get from this architecture:

  1. No race conditions: DataWriter serializes all mutations
  2. Easy testing: Mock DataStore to test DataWriter, mock DataWriter to test modules
  3. Independent modules: AnalyticsModule doesn’t know about UserModule
  4. Change resilient: Swap FileDataStore for DatabaseDataStore without touching clients

Common Mistakes I Made

Mistake 1: Treating All Data as Shared

I used to think “if multiple things need this data, it should be shared everywhere.” That led to chaos.

Fix: Identify data ownership early. Who “owns” this data? Make them the single writer.

Mistake 2: Stable Modules Depending on Unstable Ones

I once built a configuration module (very stable, rarely changes) that depended on a specific logging library (changed every month). Every time the logging library updated, my config broke.

Fix: Use dependency inversion. Make stable modules depend on stable abstractions, not unstable implementations.

Mistake 3: Ignoring SOLID as “Too Academic”

I dismissed SOLID principles as theoretical nonsense for years. Then I realized the two rules I learned are actually distilled versions of:

  • Single Responsibility Principle: Each module does one thing (single writer embodies this)
  • Dependency Inversion Principle: Depend on abstractions, not concretions (stable dependencies embodies this)

Mistake 4: Not Learning from Established Patterns

I kept trying to reinvent solutions. Then someone pointed me to Martin Fowler’s work. As one architect put it: “You’ll find that Martin Fowler already named them for you.”

Resources that helped me:

  • Clean Architecture by Robert C. Martin - explains WHY these principles matter
  • Patterns of Enterprise Application Architecture by Martin Fowler - classic design patterns
  • SOLID principles - the theoretical foundation behind practical rules

How I Use These Rules Now

Every time I design a new feature, I ask two questions:

  1. For data: “Who owns this data? Can I make them the single writer?”
  2. For modules: “Which direction do dependencies point? Are unstable things depending on stable things?”

These questions have become second nature. They help me reason about problems instead of just guessing.

When I encounter a race condition, I check for multiple writers.

When I can’t test something in isolation, I check for unstable dependencies on unstable things.

When a small change breaks everything, I check for tight coupling that violates the stability principle.

The Bottom Line

You don’t need to memorize dozens of architecture patterns. Two simple rules will carry you far:

  1. Single writer for data: Eliminates race conditions and makes debugging predictable
  2. Stable dependencies for modules: Enables testing and reduces change propagation

Start applying these in your current codebase. Ask “who owns this data?” and “which way do my dependencies point?” The answers will reveal most architecture problems before they become disasters.

For deeper learning, I recommend starting with Uncle Bob’s Clean Architecture and Martin Fowler’s design patterns. They provide the theoretical foundation that makes these practical rules even more powerful.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments