Un-F*ck Your Python Project Structure

Structure, SOLID principles, and typing for scalable sanity
Why Structure Matters
You're not building a toy script. You're building a living, breathing system. One that people will (hopefully) maintain, extend, and maybe even love.
But without structure, it's just chaos in slow motion.
A growing Python codebase without discipline becomes a tangle of:
- deeply coupled logic
- duplicate types and data models
- untyped functions
- and fragile spaghetti workflows.

The fix? Structure. Discipline. SOLID principles. Typing.
SOLID Principles in Python (Yes, They Apply)
Let’s break down a few practical ways to apply SOLID principles in Python - even without a compiler to yell at you.
S - Single Responsibility Principle (SRP)
Keep each module/class/function focused on one thing.
If your class handles business logic and talks to the database, you're already in trouble.
📌 Tip:
Split responsibilities into layers - domain logic, application services, and infrastructure.
O - Open/Closed Principle
Your modules should be open for extension, closed for modification.
Use strategy patterns, abstract base classes, or protocols.
📌 Tip:
Define interfaces in a ports/
or interfaces/
module and plug in implementations via dependency injection.
L - Liskov Substitution Principle
If you subclass something, make sure it can be used in place of its parent without breaking things.
More relevant in type-heavy or class-based Python, but still useful when designing APIs and services.
📌 Tip:
Don't subclass just to "reuse" code, prefer composition. Let duck typing + protocols help you.
I - Interface Segregation Principle
Don't force your objects to implement methods they don’t need.
In Python, this means keeping your interfaces lean - think Protocol
or @runtime_checkable
in typing
.
📌 Tip:
Keep interfaces per use case - don’t build god-objects or all-in-one service layers.
D - Dependency Inversion Principle
High-level modules should not depend on low-level details.
Define behavior in abstract terms. Concrete implementations come later.
📌 Tip:
Use constructor injection or function parameters to inject dependencies - don't hardcode infrastructure logic into your business logic.
Project Structure: Stop the Bleeding
As your project scales, it must grow in a way that keeps concerns isolated and dependencies visible.
Here’s a high-level example layout for a modular, scalable Python app:
myapp/
├── domain/
│ ├── models/ # Business entities, logic
│ ├── services/ # Stateless business rules
│ └── events.py # Domain-level events or signals
├── application/
│ ├── use_cases/ # Orchestrates workflows (sagas)
│ └── interfaces.py # Abstract ports to be implemented
├── infrastructure/
│ ├── db/ # Repositories, data access
│ ├── api/ # FastAPI, Flask, etc.
│ └── external/ # 3rd-party integrations
├── shared/
│ ├── dtos/ # Typed data transfer objects
│ ├── enums/
│ └── errors/
└── main.py # Entry point (or FastAPI app)
The Case for Shared Data Models
Ever seen a step in a workflow return a class that’s defined inside that step?
That means no other module can rely on it without creating entropy, and your code will start rotting. So other modules start redefining similar - but slightly different - structures.
Soon you’ve got multiple competing definitions of "Customer", and your sanity is officially out the window.
🛠 Fix it:
Define shared DTOs or pydantic models in a neutral location (shared/dtos/
).
Steps or modules can use internal draft models as needed, but convert to shared models before outputting.
Don't force your shared models to conform to internal module requirements. This leads to leaky abstractions and unnecessary complications.
Example:
# shared/dtos/user.py
class UserProfileDTO(BaseModel):
id: UUID
name: str
email: str
# module_a/internal_model.py
class _RawUserData(BaseModel):
full_name: str
email_address: str
internal_thing: str # Something only used internal to this module
def transform_to_profile(raw: _RawUserData) -> UserProfileDTO:
return UserProfileDTO(
id=uuid4(), # Example transformation
name=raw.full_name,
email=raw.email_address,
)
Typing Isn’t Optional Anymore
Type hints aren’t decoration—they’re your first line of defense.
Without typing, you’re leaving:
- bugs uncatchable until runtime
- refactors dangerous
- your coworkers (and future you) clueless.
Use:
mypy
orpyright
for static checking (locally and in CI)pydantic
for runtime model validation.typing.Protocol
for interface contracts.
Bonus: with proper typing, your IDE becomes 10x more powerful.
Think in Workflows (and Sagas)
Non-trivial apps often follow complex flows:
- onboarding
- subscription upgrades
- fulfillment processes
- claim processing
Each of these is a saga - a sequence of steps that pass data forward.
Design each step as a self-contained, reusable module.
Keep inputs and outputs explicit and typed.
Use a coordinator to manage orchestration.
And again: don’t let each module define its own output data types - that’s a recipe for fragmentation.
Wrapping Up
Good Python architecture isn’t about purity - it’s about practical maintainability.
Following SOLID, enforcing typing, and designing around workflows is how you future-proof your codebase.
Otherwise?
You’re just writing tomorrow’s legacy system a little faster.