Backend Architecture
This document provides an overview of the Quillium backend architecture, explaining the design principles, component organization, and data flow.
Architecture Overview
The Quillium backend follows a clean architecture approach, separating concerns into distinct layers that interact through well-defined interfaces. This design promotes maintainability, testability, and flexibility.
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ │ │ │ │ │ │
│ │ API Layer │───▶│ Services │───▶│ Data Access │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ ▲ │ │
│ │ │ │
│ └───────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Core Principles
- Separation of Concerns: Each component has a single responsibility
- Dependency Inversion: High-level modules don't depend on low-level modules
- Interface Segregation: Clients depend only on the interfaces they use
- Clean Boundaries: Clear separation between architectural layers
- Testability: Components designed to be easily testable in isolation
Architectural Layers
1. API Layer
The API layer is responsible for handling HTTP requests and WebSocket connections. It includes:
- REST API Handlers: Process HTTP requests and return responses
- WebSocket Handlers: Manage real-time communication
- Middleware: Handle cross-cutting concerns like authentication, CORS, and logging
- Request/Response Models: Define the structure of API payloads
Key components:
// Handler example
func GetChatsHandler(w http.ResponseWriter, r *http.Request) {
// Extract user ID from context (set by auth middleware)
// Call service layer to get chats
// Format and return response
}
// Middleware example
func WithAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Validate JWT token
// Set user in request context
next(w, r)
}
}
2. Service Layer
The service layer contains the business logic of the application. It:
- Orchestrates operations across multiple repositories
- Implements business rules and validation
- Manages transactions when necessary
- Remains independent of HTTP or database specifics
Key components:
// Service interface
type ChatService interface {
GetChats(userID int) ([]Chat, error)
CreateChat(userID int, title string, model string) (*Chat, error)
DeleteChat(userID int, chatID int) error
}
// Service implementation
type chatService struct {
chatRepo repository.ChatRepository
messageRepo repository.MessageRepository
}
3. Data Access Layer
The data access layer handles interactions with the database and external services:
- Repositories: Provide CRUD operations for domain entities
- Models: Define the structure of database entities
- Migrations: Manage database schema changes
- External Clients: Interact with third-party services
Key components:
// Repository interface
type ChatRepository interface {
GetByID(id int) (*Chat, error)
GetByUserID(userID int) ([]Chat, error)
Create(chat *Chat) (int, error)
Update(chat *Chat) error
Delete(id int) error
}
// Repository implementation
type PostgresChatRepository struct {
db *sql.DB
}
Cross-Cutting Concerns
Authentication and Authorization
Authentication is implemented using JWT tokens with a refresh token pattern:
- Access Tokens: Short-lived JWTs for API access
- Refresh Tokens: Long-lived tokens stored in the database
- Middleware: Validates tokens and sets user context
Error Handling
The application uses a consistent error handling approach:
- Domain Errors: Business logic errors with specific types
- HTTP Errors: Mapped from domain errors in the API layer
- Logging: Structured logging of errors with context
// Error types
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrInvalidInput = errors.New("invalid input")
)
// Error handling
func handleError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
respondJSON(w, http.StatusNotFound, ErrorResponse{Error: "Resource not found"})
case errors.Is(err, ErrUnauthorized):
respondJSON(w, http.StatusUnauthorized, ErrorResponse{Error: "Unauthorized"})
default:
respondJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
}
}
Logging
The application uses structured logging to capture important information:
// Structured logging
logger.WithFields(log.Fields{
"user_id": userID,
"action": "create_chat",
"chat_id": chatID,
}).Info("Chat created successfully")
Data Flow
Request Handling Flow
- HTTP Request: Client sends request to an endpoint
- Middleware: Request passes through middleware chain (CORS, authentication, etc.)
- Handler: Handler extracts parameters and calls appropriate service
- Service: Service implements business logic and calls repositories
- Repository: Repository interacts with the database
- Response: Data flows back through the layers to the client
Client → Middleware → Handler → Service → Repository → Database
← ← ← ← ←
WebSocket Flow
- Connection: Client establishes WebSocket connection
- Authentication: Connection is authenticated via token
- Subscription: Client subscribes to specific chat rooms
- Message Handling: Messages are processed by appropriate handlers
- Broadcasting: Messages are broadcast to subscribed clients
Dependency Injection
The application uses a simple dependency injection pattern:
// Create repositories
chatRepo := repository.NewChatRepository(db)
messageRepo := repository.NewMessageRepository(db)
// Create services
chatService := service.NewChatService(chatRepo, messageRepo)
// Create handlers
chatHandler := handler.NewChatHandler(chatService)
// Register routes
router.HandleFunc("/api/chats", chatHandler.GetChats).Methods("GET")
Configuration Management
Configuration is managed through environment variables and loaded at startup:
type Config struct {
DatabaseURL string
Port string
JWTSecret string
AccessTokenExp time.Duration
RefreshTokenExp time.Duration
LogLevel string
}
func LoadConfig() (*Config, error) {
// Load from environment variables
}
Testing Strategy
The architecture supports different testing approaches:
- Unit Tests: Test individual components in isolation
- Integration Tests: Test interactions between components
- API Tests: Test HTTP endpoints end-to-end
// Unit test example
func TestChatService_GetChats(t *testing.T) {
// Create mock repository
mockRepo := new(MockChatRepository)
mockRepo.On("GetByUserID", 1).Return([]Chat{...}, nil)
// Create service with mock
service := NewChatService(mockRepo)
// Call method and assert results
chats, err := service.GetChats(1)
assert.NoError(t, err)
assert.Len(t, chats, 2)
}
Scalability Considerations
The architecture supports horizontal scaling:
- Stateless Design: No server-side session state
- Database Connection Pooling: Efficient use of database connections
- Caching: Optional caching layer for frequently accessed data
- Message Queues: Can be added for asynchronous processing
Future Architecture Evolution
The architecture is designed to evolve with the application's needs:
- Microservices: Components can be extracted into separate services
- Event Sourcing: Can be implemented for specific domains
- CQRS: Command Query Responsibility Segregation can be added
- GraphQL: Can be implemented alongside REST API