Project: Tenant Management: An Evolutionary Project
Evolution: Evolution 2: Modular Architecture
Focus: Modular Monolith
Status: ✅ Complete
Following up on my previous post about the single-file tenant management app, today I want to share the next step - refactoring it into a modular monolith. This was a crucial learning experience that taught me the importance of clean architecture while keeping things manageable.
Evolution Context: This post is part of Evolution 2: Modular Architecture in the Tenant Management Evolutionary Project. This evolution focuses on clean architecture and modular design, building upon the single-file foundation established in Evolution 1.
Requirements Context: This modular architecture implementation is based on the detailed requirements outlined in Landlord-Tenant Management System: Requirements and Objectives. The requirements post explains the business goals and user stories that shaped both the single-file and modular implementations.
After building the single-file version (1,655 lines!), I quickly hit some walls:
The solution? Modular Monolith Architecture - a stepping stone toward microservices that gives you the benefits of separation without the complexity.
Everything mixed together in one app.py file:
Clean separation with shared database:
tenant-management-modular/
├── backend/ # Flask API backend
│ ├── models.py # Database models
│ ├── routes.py # API endpoints
│ ├── services.py # Business logic
│ └── config.py # Configuration
├── fastapi_backend/ # FastAPI alternative
├── frontend/ # React SPA
└── instance/ # Shared SQLite database
I implemented a service layer to separate business logic from API routes:
class TenantService:
@staticmethod
def get_tenant_by_id(tenant_id):
"""Get tenant by ID with error handling."""
tenant = Tenant.query.get(tenant_id)
if not tenant:
raise ValueError(f"Tenant with ID {tenant_id} not found")
return tenant
@staticmethod
def create_tenant(data):
"""Create new tenant with validation."""
# Business logic for tenant creation
tenant = Tenant(**data)
db.session.add(tenant)
db.session.commit()
return tenant
Benefits:
I built both Flask and FastAPI backends to learn different approaches:
Flask (Traditional):
@api.route('/tenants', methods=['GET'])
def get_tenants():
"""Get all tenants with optional pagination."""
try:
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
tenants = Tenant.query.paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'tenants': [tenant.to_dict() for tenant in tenants.items],
'total': tenants.total
})
except Exception as e:
return jsonify({'error': str(e)}), 500
FastAPI (Modern):
@app.get("/tenants", response_model=List[TenantOut])
def get_tenants(
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Automatic validation, type hints, auto-docs
return TenantService.get_all_tenants(db)
FastAPI Advantages:
Moved from embedded HTML to a proper React SPA:
// Clean component structure
function App() {
return (
<Router>
<div className="App">
<Navigation />
<main className="main-content">
<Routes>
<Route path="/tenants" element={<Tenants />} />
<Route path="/properties" element={<Properties />} />
<Route path="/transactions" element={<Transactions />} />
</Routes>
</main>
<Toaster position="top-right" />
</div>
</Router>
);
}
Component-Based Architecture:
const Tenants = () => {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false);
const fetchTenants = useCallback(async () => {
setLoading(true);
try {
const res = await axios.get(`/api/tenants?page=${page}&per_page=${perPage}`);
setTenants(res.data.tenants || []);
} catch (error) {
toast.error('Failed to fetch tenants');
} finally {
setLoading(false);
}
}, [page, perPage]);
// Component logic...
};
Implemented proper Flask application factory:
def create_app(config_class=Config):
"""Application factory pattern for creating Flask app."""
app = Flask(__name__)
# Initialize configuration
config_class.init_app(app)
# Initialize extensions
db.init_app(app)
# Enable CORS for frontend
CORS(app, origins=Config.CORS_ORIGINS)
# Register blueprints
app.register_blueprint(api)
app.register_blueprint(swagger_bp)
# Create database tables
with app.app_context():
db.create_all()
return app
Centralized configuration with environment variables:
class Config:
"""Application configuration class."""
# Database configuration
DATABASE_URI = os.getenv('DATABASE_URI', 'sqlite:///app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# CORS configuration for frontend
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
@staticmethod
def init_app(app):
"""Initialize Flask app with configuration."""
app.config['SQLALCHEMY_DATABASE_URI'] = Config.DATABASE_URI
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = Config.SQLALCHEMY_TRACK_MODIFICATIONS
Problem: Frontend and backend running on different ports with CORS issues.
Solution: Proper CORS configuration and proxy setup:
# Flask backend
CORS(app, origins=Config.CORS_ORIGINS)
# FastAPI backend
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Problem: Managing complex state across multiple React components.
Solution: Custom hooks and context for shared state:
// Custom hook for API calls
const useApi = (url, options = {}) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const response = await axios.get(url, options);
setData(response.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, [url]);
return { data, loading, error, refetch: fetchData };
};
Problem: Coordinating multiple services during development.
Solution: Development scripts and Docker Compose:
# start_dev.py - Coordinated development startup
def main():
print("Starting both backend and frontend servers...")
backend_thread = threading.Thread(target=run_backend)
frontend_thread = threading.Thread(target=run_frontend)
backend_thread.start()
time.sleep(2) # Give backend time to start
frontend_thread.start()
This architecture gives you the best of both worlds:
The modular structure makes it easy to extract services later:
Both backends use the same SQLite database with shared models.
Both backends expose identical REST APIs:
GET /api/tenants
POST /api/tenants
PUT /api/tenants/{id}
DELETE /api/tenants/{id}
# Option 1: FastAPI + React
uv run uvicorn fastapi_backend.main:app --reload # Port 8000
cd frontend && npm start # Port 3000
# Option 2: Flask + React
uv run python run.py # Port 5000
cd frontend && npm start # Port 3000
Even in a modular application, maintaining clear separation between:
You can explore both versions:
tenant-management-app/tenant-management-modular/The modular version includes:
The modular monolith approach was perfect for this stage of the project. It gave me:
If you’re building applications, consider the modular monolith as a stepping stone. It’s a great way to learn clean architecture principles while keeping things simple and manageable.
This modular monolith refactoring represents a significant step in the evolutionary journey:
The modular monolith approach provided the perfect balance between simplicity and structure. It demonstrated the power of clean architecture principles while maintaining the operational simplicity of a single deployment unit. This foundation sets the stage for deeper architectural exploration and the eventual transition to enterprise-ready patterns.
Next up: I’ll be exploring how to evolve this into true microservices architecture - stay tuned!