tinysvc is built following Clean Architecture principles, with a strong emphasis on SOLID design and Dependency Injection through Composition.
The innermost layer containing business entities and rules. This layer:
- Has NO dependencies on other layers
- Contains pure business logic
- Defines domain models and validation rules
Files:
paste.go: Core paste entity and validation logicerrors.go: Domain-specific errors
Key Principles:
// Domain entities are self-contained
type Paste struct {
ID string
Content string
IsMarkdown bool
ExpiresAt *time.Time
CreatedAt time.Time
}
// Business logic belongs to entities
func (p *Paste) IsExpired() bool {
if p.ExpiresAt == nil {
return false
}
return time.Now().After(*p.ExpiresAt)
}Defines interfaces for data persistence. This layer:
- Contains only interfaces, not implementations
- Allows domain/usecase layers to remain independent of storage details
- Enables easy swapping of storage backends
Key Principle - Dependency Inversion:
// Interface defined in repository package
type PasteRepository interface {
Create(ctx context.Context, paste *domain.Paste) error
GetByID(ctx context.Context, id string) (*domain.Paste, error)
Delete(ctx context.Context, id string) error
DeleteExpired(ctx context.Context) (int64, error)
}
// Implementation lives in infrastructure layer
// Use cases depend on interface, not concrete implementationContains application business logic. This layer:
- Orchestrates domain entities
- Depends only on repository interfaces
- Implements core application workflows
Key Principle - Dependency Injection:
type PasteService interface {
CreatePaste(ctx context.Context, req domain.PasteCreateRequest) (*domain.Paste, error)
GetPaste(ctx context.Context, id string) (*domain.Paste, error)
DeletePaste(ctx context.Context, id string) error
CleanupExpired(ctx context.Context) (int64, error)
}
// Service depends on interface, not concrete implementation
type pasteService struct {
repo repository.PasteRepository // Interface, not *sqlite.Repository
}
// Dependency injected through constructor
func NewPasteService(repo repository.PasteRepository) PasteService {
return &pasteService{repo: repo}
}Handles HTTP communication. This layer:
- Translates HTTP requests to use case calls
- Converts use case responses to HTTP responses
- Handles HTTP-specific concerns (routing, middleware, serialization)
- Depends on use case interfaces
Key Principle - Single Responsibility:
type PasteHandler struct {
pasteService usecase.PasteService // Interface dependency
}
// Each handler focuses only on HTTP translation
func (h *PasteHandler) CreatePaste(w http.ResponseWriter, r *http.Request) {
var req domain.PasteCreateRequest
// HTTP deserialization
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Delegate to use case
paste, err := h.pasteService.CreatePaste(r.Context(), req)
// HTTP serialization
if err != nil {
// Error handling
return
}
respondJSON(w, http.StatusCreated, paste)
}Contains external dependencies and implementations. This layer:
- Implements repository interfaces
- Handles configuration
- Manages database connections
- Is the outermost layer
Key Principle - Plugin Architecture:
// SQLite implementation of repository interface
type pasteRepository struct {
db *sql.DB
}
func NewPasteRepository(db *sql.DB) repository.PasteRepository {
return &pasteRepository{db: db}
}
// Returns interface, hiding implementation details
// Can be swapped with PostgreSQL, MongoDB, etc. without changing other layersmain.go (Composition Root)
│
├─> Infrastructure (SQLite, Config)
│ │
│ └─> Repository Interface Implementation
│
├─> Use Cases
│ │
│ └─> Depends on Repository Interface
│
└─> HTTP Handlers
│
└─> Depends on Use Case Interface
Key Point: Dependencies point inward, toward the domain layer.
Each component has one reason to change:
PasteHandler: Changes only if HTTP interface changesPasteService: Changes only if business logic changesPasteRepository: Changes only if storage mechanism changes
System is open for extension, closed for modification:
- Add new storage backend by implementing
PasteRepositoryinterface - Add new delivery mechanism (gRPC, CLI) without modifying use cases
- Add new services without modifying existing handlers
Any implementation can be substituted for its interface:
// Can swap SQLite with any other implementation
var repo repository.PasteRepository
repo = sqlite.NewPasteRepository(db)
// OR
repo = postgres.NewPasteRepository(db)
// OR
repo = memory.NewPasteRepository()Interfaces are small and focused:
// Small, focused interfaces
type PasteRepository interface {
Create(ctx context.Context, paste *domain.Paste) error
GetByID(ctx context.Context, id string) (*domain.Paste, error)
Delete(ctx context.Context, id string) error
DeleteExpired(ctx context.Context) (int64, error)
}
// Not a bloated interface with methods clients don't needHigh-level modules don't depend on low-level modules. Both depend on abstractions:
// Use case depends on interface (abstraction)
type pasteService struct {
repo repository.PasteRepository // Interface, not concrete type
}
// Concrete implementation provided at runtime
service := usecase.NewPasteService(sqlite.NewPasteRepository(db))The only place where concrete types are wired together:
func run() error {
// 1. Initialize infrastructure
db, err := sqlite.InitDB(cfg.Database.Path)
// 2. Create repository implementations
pasteRepo := sqlite.NewPasteRepository(db)
// 3. Inject into use cases
pasteService := usecase.NewPasteService(pasteRepo)
ipService := usecase.NewIPService()
// 4. Inject into handlers
router := httpdelivery.NewRouter(pasteService, ipService)
// 5. Start server
return srv.ListenAndServe()
}Benefits:
- All dependencies flow from main → outward
- Easy to see entire dependency graph
- Simple to swap implementations for testing
- Clear separation of concerns
type ShortURL struct {
ID string
LongURL string
ShortCode string
CreatedAt time.Time
}type URLRepository interface {
Create(ctx context.Context, url *domain.ShortURL) error
GetByCode(ctx context.Context, code string) (*domain.ShortURL, error)
}type urlRepository struct {
db *sql.DB
}
func NewURLRepository(db *sql.DB) repository.URLRepository {
return &urlRepository{db: db}
}type URLService interface {
ShortenURL(ctx context.Context, longURL string) (*domain.ShortURL, error)
GetURL(ctx context.Context, code string) (*domain.ShortURL, error)
}
type urlService struct {
repo repository.URLRepository
}
func NewURLService(repo repository.URLRepository) URLService {
return &urlService{repo: repo}
}type URLHandler struct {
urlService usecase.URLService
}
func NewURLHandler(urlService usecase.URLService) *URLHandler {
return &URLHandler{urlService: urlService}
}func run() error {
// ... existing setup ...
// Add new repository
urlRepo := sqlite.NewURLRepository(db)
// Add new service
urlService := usecase.NewURLService(urlRepo)
// Update router to accept new service
router := httpdelivery.NewRouter(pasteService, ipService, urlService)
// ... rest of setup ...
}Notice: No existing code needs to be modified, only extended!
// Mock repository for testing
type mockPasteRepository struct {
createFunc func(ctx context.Context, paste *domain.Paste) error
}
func (m *mockPasteRepository) Create(ctx context.Context, paste *domain.Paste) error {
if m.createFunc != nil {
return m.createFunc(ctx, paste)
}
return nil
}
func TestPasteService_CreatePaste(t *testing.T) {
mockRepo := &mockPasteRepository{
createFunc: func(ctx context.Context, paste *domain.Paste) error {
return nil
},
}
service := usecase.NewPasteService(mockRepo)
// Test business logic without database
paste, err := service.CreatePaste(context.Background(), req)
// assertions...
}func TestSQLitePasteRepository(t *testing.T) {
// Use in-memory SQLite for fast tests
db, _ := sql.Open("sqlite3", ":memory:")
repo := sqlite.NewPasteRepository(db)
// Test actual database operations
err := repo.Create(context.Background(), paste)
// assertions...
}-
SQLite Configuration
- Single connection pool (SQLite works best with 1 connection)
- Proper indexing on frequently queried columns
- Regular VACUUM to reclaim space
-
Request Handling
- 60-second timeout prevents resource exhaustion
- Rate limiting prevents abuse
- Graceful shutdown ensures clean resource cleanup
-
Memory Management
- Stream large responses instead of loading into memory
- Set reasonable content size limits (10MB)
- Periodic cleanup of expired data
-
Goroutine Management
- Limited concurrent connections via http.Server settings
- Context-based cancellation for all operations
- Proper cleanup in defer statements
The architecture supports easy addition of:
-
Authentication Layer (Priority 2)
// Add new middleware func (rt *Router) SetupAuthRoutes() http.Handler { r := chi.NewRouter() r.Use(rt.authMiddleware.Authenticate) // Protected routes }
-
Different Storage Backends
// Implement PasteRepository interface type postgresPasteRepository struct { db *pgx.Pool } // Swap in main.go pasteRepo := postgres.NewPasteRepository(db)
-
Caching Layer
type cachedPasteRepository struct { repo repository.PasteRepository cache Cache } // Decorator pattern - no changes to existing code
-
Metrics & Monitoring
// Add middleware for metrics collection r.Use(middleware.Prometheus)
- Zero configuration
- Perfect for single-instance deployments
- Low memory footprint
- File-based (easy backups)
- Sufficient performance for personal use
- Lightweight and fast
- Composable middleware
- Context-aware
- Good community support
- Testability without external dependencies
- Clear separation of concerns
- Easy to understand and maintain
- Supports future growth
tinysvc demonstrates how Clean Architecture and SOLID principles create a maintainable, testable, and extensible system even for small projects. The clear separation of concerns and dependency injection make it easy to:
- Test components in isolation
- Swap implementations without breaking changes
- Add new features without modifying existing code
- Understand the codebase quickly
This architecture scales from a personal utility on a 2GB laptop to a production service handling significant load.
---
### **22. `docs/API.md`**
```markdown
# tinysvc API Documentation
Complete API reference for tinysvc endpoints.
## Base URL
## Authentication
Currently, all endpoints are **public** and do not require authentication (Priority 1 features).
Future Priority 2 features will implement OAuth authentication (Google, GitHub).
## Rate Limiting
- **Rate**: 10 requests per second per IP
- **Burst**: 20 requests
- **Response**: `429 Too Many Requests` when exceeded
## Common Response Codes
| Code | Description |
|------|-------------|
| 200 | Success |
| 201 | Created |
| 204 | No Content (successful deletion) |
| 400 | Bad Request (validation error) |
| 404 | Not Found |
| 410 | Gone (resource expired) |
| 413 | Payload Too Large (>10MB) |
| 429 | Too Many Requests (rate limit exceeded) |
| 500 | Internal Server Error |
---
## Endpoints
### Health Check
#### GET `/health`
Check if the service is running.
**Response:**
```json
{
"status": "ok"
}
Get your public IP address.
Response:
{
"ip": "203.0.113.42"
}Headers Checked (in order):
CF-Connecting-IP(Cloudflare)X-Forwarded-ForX-Real-IPRemoteAddr(fallback)
Example:
curl http://localhost:8080/api/v1/ipCreate a new paste.
Request Body:
{
"content": "string (required, max 10MB)",
"is_markdown": "boolean (optional, default: false)",
"expiry_days": "integer (optional)"
}Expiry Days Options:
nullor0: Default (30 days)- Positive number: Custom expiration
- Negative number (e.g.,
-1): Never expires
Example: Simple Text Paste
curl -X POST http://localhost:8080/api/v1/paste \
-H "Content-Type: application/json" \
-d '{
"content": "Hello, world!",
"is_markdown": false,
"expiry_days": 30
}'Example: Markdown Paste
curl -X POST http://localhost:8080/api/v1/paste \
-H "Content-Type: application/json" \
-d '{
"content": "# Title\n\n- Item 1\n- Item 2",
"is_markdown": true,
"expiry_days": 7
}'Example: Permanent Paste
curl -X POST http://localhost:8080/api/v1/paste \
-H "Content-Type: application/json" \
-d '{
"content": "This never expires",
"is_markdown": false,
"expiry_days": -1
}'Success Response (201):
{
"id": "abc12345",
"content": "Hello, world!",
"is_markdown": false,
"expires_at": "2024-02-15T10:30:00Z",
"created_at": "2024-01-15T10:30:00Z"
}Error Responses:
400 Bad Request:
{
"error": "content cannot be empty"
}413 Payload Too Large:
{
"error": "content exceeds 10MB limit"
}Retrieve a paste by ID.
Parameters:
id(path): Paste identifier
Example:
curl http://localhost:8080/api/v1/paste/abc12345Success Response (200):
{
"id": "abc12345",
"content": "Hello, world!",
"is_markdown": false,
"expires_at": "2024-02-15T10:30:00Z",
"created_at": "2024-01-15T10:30:00Z"
}Error Responses:
404 Not Found:
{
"error": "Paste not found"
}410 Gone (Expired):
{
"error": "Paste has expired"
}Delete a paste.
Parameters:
id(path): Paste identifier
Example:
curl -X DELETE http://localhost:8080/api/v1/paste/abc12345Success Response:
- 204 No Content (empty body)
Error Responses:
404 Not Found:
{
"error": "Paste not found"
}