App-Based Architecture
SPOC's app system provides a Django-inspired way to organize your Python applications into self-contained, reusable modules. This guide explains the conceptual foundation of SPOC's app architecture.
What Are Apps in SPOC?
In SPOC, an app is a self-contained Python package that encapsulates related functionality. Similar to Django apps, SPOC apps are:
- Modular: Each app focuses on a specific domain or feature
- Self-contained: Apps have their own models, views, services, and logic
- Reusable: Apps can be shared across projects
- Isolated: Apps minimize dependencies on other apps
- Discoverable: SPOC automatically finds and loads configured apps
Apps vs. Modules
Understanding the distinction is crucial:
- App: A Python package (directory with
__init__.py) containing related functionality - Module: A Python file (
.py) within an app (e.g.,models.py,views.py)
apps/blog/ # This is an app
├── models.py # This is a module
├── views.py # This is a module
└── services.py # This is a module
App Directory Structure
SPOC expects apps to be organized in an apps/ directory at the root of your project. The framework automatically adds this directory to Python's import path.
Basic Structure
myproject/
├── apps/ # Apps directory (auto-injected into Python path)
│ ├── blog/ # Blog app
│ │ ├── __init__.py
│ │ ├── models.py # Data models
│ │ ├── views.py # View functions/classes
│ │ └── services.py # Business logic
│ │
│ ├── users/ # Users app
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── views.py
│ │ └── auth.py # Custom module
│ │
│ └── api/ # API app
│ ├── __init__.py
│ ├── endpoints.py
│ └── serializers.py
│
├── config/ # Configuration directory
│ ├── settings.py # Application settings
│ └── spoc.toml # SPOC configuration
│
└── main.py # Application entry point
Architecture Diagram
graph TB
subgraph "Project Root"
MAIN[main.py]
CONFIG[config/]
APPS[apps/]
end
subgraph "Apps Directory"
BLOG[blog/]
USERS[users/]
API[api/]
end
subgraph "Blog App"
BLOG_INIT[__init__.py]
BLOG_MODELS[models.py]
BLOG_VIEWS[views.py]
BLOG_SERVICES[services.py]
end
subgraph "Users App"
USERS_INIT[__init__.py]
USERS_MODELS[models.py]
USERS_VIEWS[views.py]
USERS_AUTH[auth.py]
end
MAIN --> CONFIG
MAIN --> APPS
APPS --> BLOG
APPS --> USERS
APPS --> API
BLOG --> BLOG_MODELS
BLOG --> BLOG_VIEWS
BLOG --> BLOG_SERVICES
USERS --> USERS_MODELS
USERS --> USERS_VIEWS
USERS --> USERS_AUTH
style APPS fill:#e1f5ff
style BLOG fill:#fff4e1
style USERS fill:#fff4e1
style API fill:#fff4e1
App Discovery and Configuration
SPOC discovers apps through configuration settings. Apps can be registered in two locations:
1. Python Settings (config/settings.py)
The INSTALLED_APPS list in your settings file defines apps that are always loaded, regardless of environment:
"""Application Settings"""
from pathlib import Path
# Base Directory
BASE_DIR: Path = Path(__file__).resolve().parent.parent
# Always-installed apps (loaded in all environments)
INSTALLED_APPS: list = [
"core", # Core functionality
"auth", # Authentication
"api", # API endpoints
]
# Plugins for extending functionality
PLUGINS: dict = {
"middleware": ["core.middleware"],
"hooks": ["auth.hooks"],
}
2. TOML Configuration (config/spoc.toml)
Environment-specific apps can be defined in spoc.toml:
[spoc]
mode = "development" # Current environment mode
# Apps loaded per environment
[spoc.apps]
production = ["analytics", "monitoring"]
staging = ["debugging"]
development = ["testing_utils", "dev_tools"]
App Loading Order
SPOC loads apps in a specific order with environment cascading:
flowchart LR
A[INSTALLED_APPS] --> B[Development Apps]
B --> C[Staging Apps]
C --> D[Production Apps]
subgraph "Development Mode"
A
B
C
D
end
subgraph "Staging Mode"
A2[INSTALLED_APPS]
C2[Staging Apps]
D2[Production Apps]
end
subgraph "Production Mode"
A3[INSTALLED_APPS]
D3[Production Apps]
end
style A fill:#4CAF50
style B fill:#FFC107
style C fill:#FF9800
style D fill:#F44336
Loading rules:
- Development mode: Loads all apps (development → staging → production)
- Staging mode: Loads staging + production apps only
- Production mode: Loads production apps only
- INSTALLED_APPS: Always loaded first, regardless of mode
Example:
# In development mode, this app list is assembled:
[
"core", # From INSTALLED_APPS
"auth", # From INSTALLED_APPS
"api", # From INSTALLED_APPS
"testing_utils", # From [spoc.apps.development]
"dev_tools", # From [spoc.apps.development]
"debugging", # From [spoc.apps.staging]
"analytics", # From [spoc.apps.production]
"monitoring", # From [spoc.apps.production]
]
The inject_apps() Function
The inject_apps() function is the mechanism that makes app imports work. It adds the apps/ directory to Python's sys.path.
How It Works
def inject_apps(
base_dir: Path,
apps_dir_name: str = "apps",
*,
position: int = 0
) -> Path:
"""
Ensure an 'apps' directory exists under `base_dir`
and inject it into Python's import path.
"""
apps_path = base_dir / apps_dir_name
ensure_directory(apps_path)
add_to_python_path(apps_path, position=position)
return apps_path
What It Does
- Creates the apps directory if it doesn't exist
- Adds the directory to
sys.pathat the specified position (default: beginning) - Returns the path to the apps directory
Automatic Injection
The Framework automatically calls inject_apps() during initialization:
class Framework:
def __init__(
self,
base_dir: Path,
schema: Schema,
echo: bool = False,
mode: FrameworkMode = "strict",
) -> None:
# Inject apps directory into Python path
inject_apps(base_dir)
# ... rest of initialization
Import Path Behavior
After inject_apps() runs, apps become importable as top-level packages:
# Before inject_apps()
# ❌ Cannot import
from blog.models import Post # ModuleNotFoundError
# After inject_apps()
# ✅ Can import
from blog.models import Post # Works!
from users.auth import login # Works!
from api.endpoints import router # Works!
Module Naming Conventions
SPOC uses a consistent naming pattern for loading modules from apps.
Fully Qualified Module Names
Apps are loaded using the pattern: appname.modulename
Examples:
"blog.models" # models.py in the blog app
"blog.views" # views.py in the blog app
"users.models" # models.py in the users app
"users.auth" # auth.py in the users app
"api.endpoints" # endpoints.py in the api app
Schema-Based Loading
The Framework Schema defines which modules to load from each app:
from spoc import Framework, Schema
schema = Schema(
# These modules will be loaded from EVERY app
modules=["models", "views", "services"],
# Module dependencies
dependencies={
"views": ["models"], # views.py depends on models.py
"services": ["models"], # services.py depends on models.py
}
)
How It Works
For each app in INSTALLED_APPS, SPOC attempts to load the specified modules:
# Given INSTALLED_APPS = ["blog", "users"]
# and modules = ["models", "views"]
# SPOC will try to load:
"blog.models" # ✅ apps/blog/models.py
"blog.views" # ✅ apps/blog/views.py
"users.models" # ✅ apps/users/models.py
"users.views" # ✅ apps/users/views.py
Strict vs. Loose Mode
SPOC supports two loading modes:
- Strict mode (default): All modules must exist in all apps, or an error is raised
- Loose mode: Missing modules are skipped without errors
# Strict mode - all apps MUST have models.py and views.py
framework = Framework(
base_dir=BASE_DIR,
schema=schema,
mode="strict" # Default
)
# Loose mode - apps without models.py or views.py are OK
framework = Framework(
base_dir=BASE_DIR,
schema=schema,
mode="loose"
)
App Isolation and Reusability
Designing apps for isolation and reusability is a core principle in SPOC.
Isolation Principles
1. Minimize Cross-App Dependencies
Apps should be as self-contained as possible:
# ❌ Bad: Direct import from another app
# In apps/blog/views.py
from users.models import User # Tight coupling!
# ✅ Good: Use dependency injection or interfaces
# In apps/blog/views.py
def create_post(user_service):
"""Create a post using an injected user service."""
user = user_service.get_user(user_id=1)
# ... create post
2. Use Components for Discoverability
Instead of direct imports, use SPOC's component system:
# In apps/users/models.py
from spoc import Components
components = Components()
components.add_type("model")
@components.register("model")
class User:
"""User model."""
id: int
username: str
# In apps/blog/services.py
# Access via the framework's component registry
def create_post(framework, user_id: int):
"""Create a post for a user."""
# Get the User component from the registry
User = framework.get_component("model", "User")
user = User(id=user_id, username="john")
# ... rest of logic
3. Clear Boundaries
Each app should have a well-defined purpose:
apps/
├── auth/ # Authentication and authorization only
├── blog/ # Blog posts and comments only
├── users/ # User profiles and management only
└── notifications/ # Notification system only
Reusability Strategies
1. Generic App Design
Design apps to be context-independent:
# apps/notifications/services.py
"""Generic notification service - works in any project."""
def send_notification(user_id: int, message: str, channel: str = "email"):
"""Send a notification to a user."""
# This doesn't know about blogs, e-commerce, etc.
# It's a generic notification service
pass
2. Configuration-Driven Behavior
Use settings to customize app behavior:
# config/settings.py
NOTIFICATION_CHANNELS = ["email", "sms", "push"]
NOTIFICATION_TEMPLATES_DIR = BASE_DIR / "templates" / "notifications"
# apps/notifications/services.py
from config import settings
def get_available_channels():
"""Get configured notification channels."""
return settings.NOTIFICATION_CHANNELS
3. App-Specific Settings
Apps can define their own configuration:
# apps/blog/settings.py
"""Blog app configuration."""
MAX_POST_LENGTH = 10000
POSTS_PER_PAGE = 20
ALLOW_COMMENTS = True
COMMENT_MODERATION = False
Best Practices for Organizing Apps
1. Single Responsibility Principle
Each app should have one clear purpose:
✅ Good:
apps/
├── authentication/ # User login, logout, password reset
├── blog/ # Blog posts and comments
└── analytics/ # Usage tracking and reporting
❌ Bad:
apps/
└── everything/ # Users, posts, comments, analytics, etc.
2. Consistent Module Structure
Use consistent naming across apps:
apps/
├── blog/
│ ├── __init__.py
│ ├── models.py # Data models
│ ├── views.py # Request handlers
│ ├── services.py # Business logic
│ └── utils.py # Helper functions
│
└── users/
├── __init__.py
├── models.py # Data models (same structure)
├── views.py # Request handlers
├── services.py # Business logic
└── utils.py # Helper functions
3. App Dependencies in Schema
Declare dependencies explicitly in your schema:
schema = Schema(
modules=["models", "views", "services"],
dependencies={
"views": ["models"], # views need models
"services": ["models"], # services need models
}
)
4. Use __init__.py for Public API
Expose only what's needed:
# apps/blog/__init__.py
"""Blog app public API."""
from .models import Post, Comment
from .services import create_post, delete_post
__all__ = [
"Post",
"Comment",
"create_post",
"delete_post",
]
5. Documentation in Each App
Document app purpose and usage:
# apps/blog/__init__.py
"""
Blog App
========
Provides blog post and comment functionality.
Components:
- Post model: Blog post data
- Comment model: Comment data
- create_post(): Create new posts
- delete_post(): Delete posts
Dependencies:
- users app (for authentication)
Configuration:
- MAX_POST_LENGTH: Maximum post length
- ALLOW_COMMENTS: Enable/disable comments
"""
6. Testing Strategy
Each app should have its own tests:
tests/
├── test_blog.py # Tests for blog app
├── test_users.py # Tests for users app
└── test_api.py # Tests for api app
7. Environment-Specific Apps
Use TOML configuration for environment-specific apps:
[spoc.apps]
production = [
"monitoring", # Only in production
"analytics", # Only in production
]
development = [
"debug_toolbar", # Only in development
"test_data", # Only in development
]
Complete Example
Here's a complete example showing the app system in action:
Project Structure
myproject/
├── apps/
│ ├── users/
│ │ ├── __init__.py
│ │ └── models.py
│ │
│ └── blog/
│ ├── __init__.py
│ ├── models.py
│ ├── views.py
│ └── services.py
│
├── config/
│ ├── settings.py
│ └── spoc.toml
│
└── main.py
Configuration
# config/settings.py
from pathlib import Path
BASE_DIR: Path = Path(__file__).resolve().parent.parent
INSTALLED_APPS: list = [
"users",
"blog",
]
PLUGINS: dict = {
"middleware": [],
"hooks": [],
}
App Implementation
# apps/users/models.py
"""User models."""
import dataclasses as dc
from spoc import Components
components = Components()
components.add_type("model")
@dc.dataclass
@components.register("model")
class User:
"""User model."""
id: int
username: str
email: str
# apps/blog/models.py
"""Blog models."""
import dataclasses as dc
from spoc import Components
components = Components()
components.add_type("model")
@dc.dataclass
@components.register("model")
class Post:
"""Blog post model."""
id: int
title: str
content: str
author_id: int
# apps/blog/views.py
"""Blog views."""
from spoc import Components
components = Components()
components.add_type("view")
@components.register("view")
def list_posts():
"""List all blog posts."""
return {"action": "list_posts"}
@components.register("view")
def create_post():
"""Create a new blog post."""
return {"action": "create_post"}
# apps/blog/services.py
"""Blog business logic."""
from spoc import Components
components = Components()
components.add_type("service")
@components.register("service")
class PostService:
"""Service for managing blog posts."""
def create_post(self, title: str, content: str, author_id: int):
"""Create a new blog post."""
# Business logic here
return {"title": title, "content": content, "author_id": author_id}
def delete_post(self, post_id: int):
"""Delete a blog post."""
# Business logic here
return {"deleted": post_id}
Application Entry Point
# main.py
"""Main application entry point."""
from pathlib import Path
from spoc import Framework, Schema
BASE_DIR = Path(__file__).resolve().parent
# Define schema
schema = Schema(
modules=["models", "views", "services"],
dependencies={
"views": ["models"],
"services": ["models"],
}
)
# Initialize framework
framework = Framework(
base_dir=BASE_DIR,
schema=schema,
echo=True,
mode="strict"
)
# Access components
print("Installed apps:", framework.installed_apps)
# Output: ['users', 'blog']
# Get a specific component
Post = framework.get_component("model", "Post")
print(f"Post component: {Post}")
# Shutdown when done
framework.shutdown()
Summary
SPOC's app-based architecture provides:
- Clear Organization: Apps group related functionality
- Automatic Discovery: Framework finds and loads apps from configuration
- Path Injection:
inject_apps()makes apps importable - Naming Conventions: Consistent
appname.modulenamepattern - Isolation: Apps are self-contained with minimal coupling
- Reusability: Apps can be shared across projects
- Environment Control: Load different apps per environment
By following these patterns and best practices, you can build maintainable, scalable applications with SPOC's app system.
Next Steps
- Quick Start Guide - Build your first SPOC app
- Configuration Guide - Learn about settings and TOML config
- Components API - Component registration and discovery
- Framework API - Framework lifecycle and management