mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
Repository Structure:
- Move files from cluttered root directory into organized structure
- Create archive/ for archived data and scraper results
- Create bugulma/ for the complete application (frontend + backend)
- Create data/ for sample datasets and reference materials
- Create docs/ for comprehensive documentation structure
- Create scripts/ for utility scripts and API tools
Backend Implementation:
- Implement 3 missing backend endpoints identified in gap analysis:
* GET /api/v1/organizations/{id}/matching/direct - Direct symbiosis matches
* GET /api/v1/users/me/organizations - User organizations
* POST /api/v1/proposals/{id}/status - Update proposal status
- Add complete proposal domain model, repository, and service layers
- Create database migration for proposals table
- Fix CLI server command registration issue
API Documentation:
- Add comprehensive proposals.md API documentation
- Update README.md with Users and Proposals API sections
- Document all request/response formats, error codes, and business rules
Code Quality:
- Follow existing Go backend architecture patterns
- Add proper error handling and validation
- Match frontend expected response schemas
- Maintain clean separation of concerns (handler -> service -> repository)
1266 lines
38 KiB
Markdown
1266 lines
38 KiB
Markdown
# Sellable MVP Concept: Turash
|
|
|
|
A lean, marketable MVP approach to industrial resource matching platform.
|
|
|
|
---
|
|
|
|
## MVP Vision: "Resource Dating App for Businesses"
|
|
|
|
**The Pitch**: A simple platform where local businesses declare what they consume and emit, and we find profitable matches. Start with heat (tangible, measurable), expand later.
|
|
|
|
---
|
|
|
|
## Core MVP Value Proposition
|
|
|
|
### Problem
|
|
|
|
- Businesses waste money on utilities they could share
|
|
- Neighbors don't know each other's resource needs
|
|
- No simple way to find "I have X, you need X" opportunities
|
|
|
|
### Solution (MVP)
|
|
|
|
1. **Simple Declaration**: Business fills 5-minute form
|
|
2. **Automatic Matching**: System finds compatible neighbors
|
|
3. **Clear ROI**: "Connect to Business Y → Save €18k/year"
|
|
|
|
### Why It Works
|
|
|
|
- **High Value**: Rare but valuable matches (1-3 per year, big savings)
|
|
- **Low Friction**: Simple data entry, clear results
|
|
- **Network Effects**: More businesses = better matches
|
|
|
|
---
|
|
|
|
## MVP Scope: What to Build
|
|
|
|
### ✅ In Scope (Must Have)
|
|
|
|
1. **Business Registration**
|
|
- Company name, location (address), contact
|
|
- Simple resource declaration:
|
|
- "I consume: heat @ X°C, Y kWh/month"
|
|
- "I emit: heat @ X°C, Y kWh/month"
|
|
- Or: "I need cold storage for X m³"
|
|
|
|
2. **Basic Matching Engine**
|
|
- Distance-based filtering (within 5km)
|
|
- Temperature compatibility (±10°C tolerance)
|
|
- Simple economic scoring (estimated savings)
|
|
|
|
3. **Match Results**
|
|
- Ranked list: "You could save €X/year with Business Y"
|
|
- Distance, estimated savings, basic compatibility
|
|
|
|
4. **Contact Introduction**
|
|
- Platform facilitates initial contact
|
|
- Simple messaging or email intro
|
|
|
|
5. **Map View**
|
|
- Show businesses on map
|
|
- Visualize potential connections
|
|
|
|
### ❌ Out of Scope (Later)
|
|
|
|
- Complex multi-party matching
|
|
- Full economic optimization (MILP)
|
|
- Real-time IoT data
|
|
- Advanced analytics
|
|
- Mobile apps
|
|
- Enterprise features
|
|
- Multiple resource types (start with heat only)
|
|
|
|
---
|
|
|
|
## MVP Tech Stack (Horizontal Foundation)
|
|
|
|
> **Note**: This MVP concept now includes production-ready foundations addressing all critical gaps: authentication, authorization, input validation, structured logging (slog), error handling, connection management, graceful shutdown, rate limiting, health checks, and security headers.
|
|
|
|
### Backend (Go 1.25)
|
|
|
|
**Core Stack - Full Foundation**:
|
|
|
|
- **HTTP Framework**: Echo (clean API, built-in validation, mature)
|
|
- **Graph Database**: Neo4j (foundation for graph queries from day 1)
|
|
- Simple graph structure (Business → Site → ResourceFlow)
|
|
- Basic Cypher queries (no complex traversals yet)
|
|
- Single instance (community edition free)
|
|
- Connection pooling configured
|
|
- Indexes: ResourceFlow.type + direction, Site.id
|
|
- **Geospatial Database**: PostgreSQL + PostGIS with GORM
|
|
- GORM for simpler CRUD operations
|
|
- PostGIS spatial queries (distance calculations)
|
|
- Site locations with spatial indexes (GIST on location)
|
|
- Event-driven sync from Neo4j
|
|
- Connection pooling via GORM
|
|
- **Event Processing**: Watermill (Go-native event processing library)
|
|
- Start with in-memory Go channels (zero external deps)
|
|
- Clean pubsub interface for easy backend switching
|
|
- Can migrate to Redis Streams, Kafka, NATS later (same code)
|
|
- Foundation for event-driven architecture
|
|
- Production-ready event processing patterns
|
|
- **Cache**: Redis (match results, sessions)
|
|
- Connection pooling configured
|
|
- Session storage for auth
|
|
- **Authentication**: JWT-based auth
|
|
- Simple JWT tokens (no OAuth2 provider needed yet)
|
|
- Echo JWT middleware
|
|
- Business registration creates account + JWT
|
|
- **Logging**: Go's `log/slog` (structured logging)
|
|
- JSON format for production
|
|
- Text format for development
|
|
- Log levels: DEBUG, INFO, WARN, ERROR
|
|
- Request logging middleware
|
|
- **Validation**: `github.com/go-playground/validator/v10`
|
|
- Struct validation on all API endpoints
|
|
- User-friendly error messages
|
|
- **Configuration**: Environment variables
|
|
- `.env` file for local development
|
|
- Environment-specific config (dev, prod)
|
|
- **Hosting**: Managed services (Railway/Render) or single server
|
|
|
|
**Why Full Foundation Now**:
|
|
|
|
- Build right architecture from day 1
|
|
- Each component simple but extensible
|
|
- No expensive migrations later
|
|
- Horizontal MVP: all layers present, keep each simple
|
|
- Production-ready from launch
|
|
|
|
### Frontend
|
|
|
|
- **React + Vite**: Fast development
|
|
- **Mapbox GL JS**: Map visualization (free tier: 50k loads/month)
|
|
- **Tailwind CSS**: Rapid UI development
|
|
- **shadcn/ui**: Pre-built components
|
|
|
|
**Deploy**: Vercel or Netlify (free tier)
|
|
|
|
### Infrastructure (Complete Foundation, Simple Setup)
|
|
|
|
- **Application Host**: Railway, Render, or DigitalOcean ($10-20/month)
|
|
- Single Go binary serving API
|
|
- **Neo4j**: Neo4j Aura Free tier (50k nodes) or self-hosted single instance
|
|
- Community edition if self-hosting (free)
|
|
- **PostgreSQL + PostGIS**: Railway, Supabase free tier, or Neon
|
|
- **Event Processing**: Watermill with in-memory pubsub (built-in, zero cost)
|
|
- Later: Switch to Redis Streams, Kafka, or NATS (same interface)
|
|
- **Redis**: Redis Cloud free tier or Railway addon
|
|
- **Domain**: $10/year
|
|
|
|
**Total Cost**: ~$30-70/month for MVP (foundation stack)
|
|
|
|
---
|
|
|
|
## MVP Data Model (Horizontal Foundation)
|
|
|
|
### Neo4j Graph Structure (Foundation)
|
|
|
|
```cypher
|
|
// Nodes
|
|
(:Business {
|
|
id: UUID,
|
|
name: String,
|
|
contact_email: String,
|
|
contact_phone: String
|
|
})
|
|
|
|
(:Site {
|
|
id: UUID,
|
|
address: String,
|
|
latitude: Float,
|
|
longitude: Float
|
|
})
|
|
|
|
(:ResourceFlow {
|
|
id: UUID,
|
|
direction: "input" | "output",
|
|
type: "heat", // MVP: only heat
|
|
temperature_celsius: Float,
|
|
quantity_kwh_per_month: Float,
|
|
cost_per_kwh_euro: Float
|
|
})
|
|
|
|
// Relationships
|
|
(Business)-[:OPERATES_AT]->(Site)
|
|
(Site)-[:HOSTS]->(ResourceFlow)
|
|
(ResourceFlow)-[:MATCHABLE_TO {
|
|
distance_km: Float,
|
|
savings_euro_per_year: Float,
|
|
score: Float
|
|
}]->(ResourceFlow)
|
|
```
|
|
|
|
**Why Neo4j from Day 1**:
|
|
|
|
- Right foundation for graph queries
|
|
- Simple structure now, add complexity later
|
|
- No expensive migration
|
|
- Natural fit for matching relationships
|
|
|
|
### PostgreSQL + PostGIS with GORM (Geospatial)
|
|
|
|
```go
|
|
// GORM model for PostGIS
|
|
type SiteGeo struct {
|
|
SiteID uuid.UUID `gorm:"type:uuid;primary_key"`
|
|
BusinessID uuid.UUID `gorm:"type:uuid;not null;index"`
|
|
Latitude float64 `gorm:"not null"`
|
|
Longitude float64 `gorm:"not null"`
|
|
Location postgis.Geometry `gorm:"type:geometry(Point,4326);not null;index:idx_location"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
}
|
|
|
|
// GORM auto-migration
|
|
db.AutoMigrate(&SiteGeo{})
|
|
|
|
// Create spatial index
|
|
db.Exec("CREATE INDEX IF NOT EXISTS idx_sites_location ON sites_geo USING GIST(location)")
|
|
```
|
|
|
|
**Why GORM + PostGIS**:
|
|
|
|
- GORM simplifies CRUD operations
|
|
- PostGIS handles fast spatial queries (distance calculations)
|
|
- Spatial indexes optimized for radius searches
|
|
- Neo4j handles relationships, PostGIS handles geography
|
|
- Event-driven sync keeps data in sync
|
|
|
|
### Data Flow (Simple but Complete - Go Native)
|
|
|
|
```
|
|
User Input → API (Echo) → Auth Check → Validation → Watermill → Goroutine Worker
|
|
↓ ↓
|
|
JWT Verify Neo4j Write
|
|
↓ ↓
|
|
Rate Limit PostGIS Sync (async goroutine)
|
|
↓
|
|
Match Computation (goroutine)
|
|
↓
|
|
Redis Cache (matches)
|
|
```
|
|
|
|
### Security & Authentication
|
|
|
|
**Authentication**:
|
|
|
|
- **JWT-based authentication** (simple, no OAuth2 provider needed yet)
|
|
- Business registration creates account + JWT token
|
|
- All API endpoints require authentication (except `/health` and public match summaries)
|
|
- Echo JWT middleware for token validation
|
|
- Token stored in HTTP-only cookie or Authorization header
|
|
|
|
**Authorization**:
|
|
|
|
- **Role-Based Access Control (RBAC)**:
|
|
- **Business Owner**: Can view/edit own business data, view own matches
|
|
- **Public/Viewer**: Can view public match summaries (without sensitive data)
|
|
- Ownership validation: Business can only access own data
|
|
- API endpoints check ownership before allowing operations
|
|
|
|
**API Security**:
|
|
|
|
- **Rate Limiting**: 100 requests/minute per IP
|
|
- **CORS**: Configured for frontend domain only
|
|
- **Request Size Limits**: 10MB max body size
|
|
- **HTTPS**: Enforced in production
|
|
- **Security Headers**: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
// JWT middleware
|
|
func JWTAuthMiddleware() echo.MiddlewareFunc {
|
|
return middleware.JWTWithConfig(middleware.JWTConfig{
|
|
SigningKey: []byte(secretKey),
|
|
TokenLookup: "header:Authorization",
|
|
AuthScheme: "Bearer",
|
|
})
|
|
}
|
|
|
|
// Ownership check middleware
|
|
func RequireOwnership(businessID uuid.UUID) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserIDFromToken(c)
|
|
if userID != businessID {
|
|
return echo.ErrForbidden
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Handling & Validation
|
|
|
|
**Error Handling**:
|
|
|
|
- **Standardized Error Response Format**:
|
|
|
|
```go
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details interface{} `json:"details,omitempty"`
|
|
}
|
|
```
|
|
|
|
- **HTTP Status Codes**:
|
|
- 400 Bad Request (validation errors)
|
|
- 401 Unauthorized (missing/invalid token)
|
|
- 403 Forbidden (no permission)
|
|
- 404 Not Found
|
|
- 500 Internal Server Error
|
|
- **Error Logging**: All errors logged with context using `slog`
|
|
- **Error Wrapping**: `fmt.Errorf("operation failed: %w", err)` for error context
|
|
|
|
**Input Validation**:
|
|
|
|
- **Struct Validation**: `github.com/go-playground/validator/v10`
|
|
- **Validation Rules**:
|
|
- Temperature: -50°C to 500°C for heat
|
|
- Quantity: Must be > 0
|
|
- Email: Valid email format
|
|
- Location: Must include latitude/longitude
|
|
- **User-Friendly Error Messages**: Clear validation error messages
|
|
- **Cross-Entity Validation**: Site belongs to Business, ResourceFlow belongs to Site
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
type ResourceFlow struct {
|
|
TemperatureCelsius float64 `json:"temperature_celsius" validate:"required,min=-50,max=500"`
|
|
QuantityKwhPerMonth float64 `json:"quantity_kwh_per_month" validate:"required,gt=0"`
|
|
Direction string `json:"direction" validate:"required,oneof=input output"`
|
|
}
|
|
|
|
func createResourceFlow(c echo.Context) error {
|
|
var rf ResourceFlow
|
|
if err := c.Bind(&rf); err != nil {
|
|
return echo.NewHTTPError(400, "invalid request body")
|
|
}
|
|
|
|
if err := validate.Struct(rf); err != nil {
|
|
return echo.NewHTTPError(400, err.Error())
|
|
}
|
|
|
|
// ... create logic ...
|
|
}
|
|
```
|
|
|
|
### Logging & Observability (slog)
|
|
|
|
**Structured Logging with `log/slog`**:
|
|
|
|
- **JSON Format**: Production (machine-readable)
|
|
- **Text Format**: Development (human-readable)
|
|
- **Log Levels**: DEBUG, INFO, WARN, ERROR
|
|
- **Context Fields**: Request ID, user ID, business ID, duration
|
|
- **Request Logging Middleware**: Log all HTTP requests (method, path, status, duration)
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
)
|
|
|
|
// Initialize logger based on environment
|
|
var logger *slog.Logger
|
|
|
|
func initLogger(env string) {
|
|
var handler slog.Handler
|
|
|
|
if env == "production" {
|
|
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: slog.LevelInfo,
|
|
})
|
|
} else {
|
|
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
})
|
|
}
|
|
|
|
logger = slog.New(handler)
|
|
}
|
|
|
|
// Request logging middleware
|
|
func RequestLogger() echo.MiddlewareFunc {
|
|
return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
|
LogStatus: true,
|
|
LogURI: true,
|
|
LogMethod: true,
|
|
LogError: true,
|
|
LogDuration: true,
|
|
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
|
|
logger.Info("request",
|
|
"method", v.Method,
|
|
"uri", v.URI,
|
|
"status", v.Status,
|
|
"duration", v.Duration,
|
|
"error", v.Error,
|
|
)
|
|
return nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// Error logging
|
|
func handleError(c echo.Context, err error) error {
|
|
logger.Error("api error",
|
|
"error", err,
|
|
"path", c.Path(),
|
|
"method", c.Request().Method,
|
|
)
|
|
|
|
return c.JSON(500, ErrorResponse{
|
|
Error: "internal_server_error",
|
|
Message: "An error occurred",
|
|
})
|
|
}
|
|
```
|
|
|
|
**Metrics** (Basic):
|
|
|
|
- Request count per endpoint
|
|
- Error rate
|
|
- Response time (p50, p95, p99)
|
|
- Database connection pool usage
|
|
- Cache hit rate
|
|
|
|
### Database Connection Management
|
|
|
|
**Connection Pooling**:
|
|
|
|
- **Neo4j**: Configure max connections (default: 100), connection timeout (30s)
|
|
- **PostgreSQL (GORM)**: Configure max open connections (25), max idle (5), connection lifetime
|
|
- **Redis**: Configure pool size (10 connections), connection timeout (5s)
|
|
|
|
**Health Checks**:
|
|
|
|
- `/health` endpoint checks database connections
|
|
- Startup health check: Verify all DB connections before accepting requests
|
|
- Periodic health checks: Monitor connection health
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
// Health check endpoint
|
|
func healthCheck(c echo.Context) error {
|
|
// Check Neo4j
|
|
if err := neo4jDriver.VerifyConnectivity(); err != nil {
|
|
return c.JSON(503, map[string]string{"status": "unhealthy", "neo4j": "down"})
|
|
}
|
|
|
|
// Check PostgreSQL
|
|
sqlDB, _ := postgresDB.DB()
|
|
if err := sqlDB.Ping(); err != nil {
|
|
return c.JSON(503, map[string]string{"status": "unhealthy", "postgres": "down"})
|
|
}
|
|
|
|
// Check Redis
|
|
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
|
return c.JSON(503, map[string]string{"status": "unhealthy", "redis": "down"})
|
|
}
|
|
|
|
return c.JSON(200, map[string]string{"status": "healthy"})
|
|
}
|
|
```
|
|
|
|
### Graceful Shutdown
|
|
|
|
**Shutdown Handler**:
|
|
|
|
- Handle SIGTERM/SIGINT signals
|
|
- Graceful HTTP server shutdown (wait up to 30s for in-flight requests)
|
|
- Close database connections properly
|
|
- Stop Watermill subscribers gracefully
|
|
- Exit cleanly
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
func main() {
|
|
// ... setup ...
|
|
|
|
// Start server in goroutine
|
|
go func() {
|
|
if err := server.Start(":8080"); err != nil && err != http.ErrServerClosed {
|
|
logger.Error("server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
// Wait for interrupt signal
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
logger.Info("shutting down server")
|
|
|
|
// Graceful shutdown
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := server.Shutdown(ctx); err != nil {
|
|
logger.Error("server shutdown error", "error", err)
|
|
}
|
|
|
|
// Close database connections
|
|
neo4jDriver.Close()
|
|
postgresDB.Close()
|
|
redisClient.Close()
|
|
|
|
// Stop Watermill subscribers
|
|
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
pubsub.Close()
|
|
|
|
logger.Info("server stopped")
|
|
}
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
**Rate Limiting**:
|
|
|
|
- **IP-based rate limiting**: 100 requests/minute per IP
|
|
- **Rate limit headers**: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
|
- **Error response**: 429 Too Many Requests
|
|
|
|
**Implementation Example**:
|
|
|
|
```go
|
|
// Simple in-memory rate limiter (upgrade to Redis later)
|
|
func RateLimitMiddleware() echo.MiddlewareFunc {
|
|
limiter := rate.NewLimiter(rate.Every(time.Minute), 100)
|
|
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
ip := c.RealIP()
|
|
if !limiter.Allow() {
|
|
return c.JSON(429, ErrorResponse{
|
|
Error: "too_many_requests",
|
|
Message: "Rate limit exceeded",
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## MVP Matching Algorithm (Horizontal - Simple but Complete)
|
|
|
|
### Architecture: Multi-Step Pipeline (Foundation Pattern)
|
|
|
|
```
|
|
1. PostGIS: Fast spatial pre-filter
|
|
2. Neo4j: Graph traversal for compatibility
|
|
3. Go: Economic scoring
|
|
4. Redis: Cache results
|
|
```
|
|
|
|
### Step 1: Spatial Pre-Filter (PostGIS via GORM)
|
|
|
|
```go
|
|
// Using GORM with PostGIS
|
|
func FindSitesWithinRadius(ctx context.Context, db *gorm.DB, lat, lon float64, radiusKm float64) ([]SiteGeo, error) {
|
|
var sites []SiteGeo
|
|
|
|
query := `
|
|
SELECT site_id, business_id, latitude, longitude
|
|
FROM sites_geo
|
|
WHERE ST_DWithin(
|
|
location::geography,
|
|
ST_MakePoint(?, ?)::geography,
|
|
?
|
|
)
|
|
ORDER BY ST_Distance(location, ST_MakePoint(?, ?))
|
|
`
|
|
|
|
radiusMeters := radiusKm * 1000
|
|
err := db.WithContext(ctx).
|
|
Raw(query, lon, lat, radiusMeters, lon, lat).
|
|
Scan(&sites).
|
|
Error
|
|
|
|
return sites, err
|
|
}
|
|
```
|
|
|
|
**Why PostGIS via GORM**:
|
|
|
|
- GORM simplifies database operations
|
|
- Spatial indexes are optimized for radius queries
|
|
- Filters 99% of data before Neo4j query
|
|
- Much faster than Neo4j spatial queries at scale
|
|
|
|
### Step 2: Graph Traversal (Neo4j)
|
|
|
|
```cypher
|
|
// Simple graph query (foundation for complex traversals later)
|
|
MATCH (sourceFlow:ResourceFlow)-[:HOSTS]->(sourceSite:Site),
|
|
(targetFlow:ResourceFlow)-[:HOSTS]->(targetSite:Site),
|
|
(sourceFlow)-[:MATCHABLE_TO]->(targetFlow)
|
|
WHERE sourceFlow.direction = 'output'
|
|
AND targetFlow.direction = 'input'
|
|
AND sourceFlow.type = 'heat'
|
|
AND targetFlow.type = 'heat'
|
|
AND sourceSite.id IN $siteIds // from PostGIS filter
|
|
AND ABS(sourceFlow.temperature_celsius - targetFlow.temperature_celsius) <= 10
|
|
RETURN sourceFlow, targetFlow,
|
|
distance(point({longitude: sourceSite.longitude, latitude: sourceSite.latitude}),
|
|
point({longitude: targetSite.longitude, latitude: targetSite.latitude})) AS distance
|
|
```
|
|
|
|
**Why Neo4j**:
|
|
|
|
- Natural graph traversal (relationships already defined)
|
|
- Foundation for complex multi-hop queries later
|
|
- Simple query now, add complexity as needed
|
|
|
|
### Step 3: Economic Scoring (Go)
|
|
|
|
```go
|
|
// Simple calculation (foundation for MILP optimization later)
|
|
func calculateSavings(inputFlow, outputFlow ResourceFlow, distanceKm float64) float64 {
|
|
// Basic savings = cost difference * quantity
|
|
savingsPerMonth := (inputFlow.CostPerKwh - outputFlow.CostPerKwh) * outputFlow.QuantityKwhPerMonth
|
|
savingsPerYear := savingsPerMonth * 12
|
|
|
|
// Simple transport cost (linear model, improve later)
|
|
transportCost := distanceKm * 0.5 // €0.50/km simple model
|
|
|
|
return savingsPerYear - (transportCost * outputFlow.QuantityKwhPerMonth * 12)
|
|
}
|
|
```
|
|
|
|
**Why Simple Scoring**:
|
|
|
|
- Order-of-magnitude estimates find 80% of opportunities
|
|
- Foundation pattern: simple function → complex optimization later
|
|
- Fast to compute and understand
|
|
|
|
### Step 4: Cache Results (Redis)
|
|
|
|
```go
|
|
// Cache match results (5-minute TTL)
|
|
cacheKey := fmt.Sprintf("matches:%s", sourceFlowID)
|
|
redis.Set(cacheKey, matches, 5*time.Minute)
|
|
```
|
|
|
|
**Foundation Pattern**:
|
|
|
|
- Each layer present but simple
|
|
- Can enhance each independently later
|
|
- No architectural rewrites needed
|
|
|
|
---
|
|
|
|
## MVP User Flow
|
|
|
|
### 1. Business Signs Up (5 minutes)
|
|
|
|
- Enter business name, location, contact
|
|
- Declare one resource flow (e.g., "I emit 500 kWh/month heat @ 45°C")
|
|
|
|
### 2. See Matches (Instant)
|
|
|
|
- Dashboard shows: "You could save €12,000/year with Factory B (2.3km away)"
|
|
- Click to see details: distance, temperature match, estimated savings
|
|
|
|
### 3. Request Contact (One Click)
|
|
|
|
- "I'm interested" button
|
|
- Platform sends email to both businesses
|
|
- They take conversation offline
|
|
|
|
### 4. Mark Status
|
|
|
|
- "Contacted", "Not Interested", "Match Successful"
|
|
- Simple feedback loop
|
|
|
|
---
|
|
|
|
## MVP Features (Prioritized)
|
|
|
|
### Phase 1: Launch (Week 1-4)
|
|
|
|
1. ✅ Business registration form
|
|
2. ✅ Basic matching algorithm
|
|
3. ✅ Match results page
|
|
4. ✅ Simple map view
|
|
5. ✅ Contact request functionality
|
|
|
|
### Phase 2: Engagement (Week 5-8)
|
|
|
|
6. Email notifications for new matches
|
|
7. Business profile page
|
|
8. Match history/status tracking
|
|
9. Basic analytics (match success rate)
|
|
|
|
### Phase 3: Growth (Week 9-12)
|
|
|
|
10. Social proof (success stories)
|
|
11. Invite other businesses
|
|
12. Simple ROI calculator
|
|
13. Mobile-responsive design
|
|
|
|
---
|
|
|
|
## MVP Go-to-Market Strategy
|
|
|
|
### Target: Single Industrial Park or District
|
|
|
|
**Why**:
|
|
|
|
- Concentrated geography = easier matching
|
|
- Word-of-mouth spreads faster
|
|
- Lower customer acquisition cost
|
|
- Can validate with 20-50 businesses
|
|
|
|
### Sales Approach
|
|
|
|
1. **Seed 5-10 Businesses**
|
|
- Offer free access
|
|
- Get real data
|
|
- Document first success story
|
|
|
|
2. **Cold Outreach**
|
|
- Identify 50 businesses in target area
|
|
- Email: "We found €X savings opportunity in your area"
|
|
- Free to join, pay only on successful match (lead fee model)
|
|
|
|
3. **Network Effects**
|
|
- First match → press release → more signups
|
|
- Success stories on platform
|
|
- Referral program
|
|
|
|
### Pricing Model (MVP)
|
|
|
|
**Free Tier**:
|
|
|
|
- List business
|
|
- See matches
|
|
- Request contact
|
|
|
|
**Success Fee** (Only if match succeeds):
|
|
|
|
- €500-2000 per successful match
|
|
- Or: 5% of first year savings
|
|
|
|
**Why This Model**:
|
|
|
|
- No upfront cost = easier adoption
|
|
- Businesses only pay when they get value
|
|
- Aligns platform incentives with customer success
|
|
|
|
---
|
|
|
|
## MVP Success Metrics
|
|
|
|
### Week 1-4 (Launch)
|
|
|
|
- **Goal**: 20 businesses registered
|
|
- **Goal**: 5 matches found
|
|
- **Goal**: 1 business requests contact
|
|
|
|
### Week 5-12 (Validation)
|
|
|
|
- **Goal**: 100 businesses registered
|
|
- **Goal**: 20 matches found
|
|
- **Goal**: 3 successful connections
|
|
- **Goal**: €50k+ total savings identified
|
|
|
|
### Month 4-6 (Traction)
|
|
|
|
- **Goal**: 1st paying customer
|
|
- **Goal**: 500 businesses
|
|
- **Goal**: 10% match success rate
|
|
- **Goal**: Revenue: €5k-10k/month
|
|
|
|
---
|
|
|
|
## MVP Technical Architecture (Horizontal Foundation)
|
|
|
|
### Architecture (All Layers Present, Each Simple)
|
|
|
|
```
|
|
┌─────────────┐
|
|
│ Frontend │ React + Vite (Vercel/Netlify)
|
|
│ (React) │
|
|
└──────┬──────┘
|
|
│ HTTPS
|
|
┌──────▼──────────────────────────────┐
|
|
│ API Server (Go) - Echo │
|
|
│ ├─ Auth Middleware (JWT) │
|
|
│ ├─ Validation Middleware │
|
|
│ ├─ Rate Limiting │
|
|
│ ├─ Request Logging (slog) │
|
|
│ ├─ Error Handling │
|
|
│ ├─ HTTP Handlers │
|
|
│ └─ Watermill Subscribers │
|
|
└───┬───────────┬─────────────────────┘
|
|
│ │
|
|
┌───▼───┐ ┌──▼────┐
|
|
│Watermill│ │ Redis │ Cache, sessions
|
|
│(in-mem)│ │ │
|
|
└───┬───┘ └───────┘
|
|
│
|
|
┌───▼──────────┬───────────┐
|
|
│ │ │
|
|
│ Neo4j │ PostgreSQL│ Graph DB │ PostGIS + GORM
|
|
│ (Graph) │ (Geospatial)│
|
|
│ (with │ (with │
|
|
│ pooling) │ pooling) │
|
|
└──────────────┴───────────┘
|
|
|
|
Logging: slog (JSON for prod, text for dev)
|
|
Monitoring: /health endpoint, basic metrics
|
|
Shutdown: Graceful (SIGTERM/SIGINT handler)
|
|
```
|
|
|
|
### Services (Horizontal MVP - All Present, Simple Config)
|
|
|
|
**Application Layer**:
|
|
|
|
- **Single Go Binary**:
|
|
- HTTP API (Echo)
|
|
- JWT Authentication & Authorization
|
|
- Input Validation (`go-playground/validator`)
|
|
- Structured Logging (`log/slog`)
|
|
- Error Handling (standardized responses)
|
|
- Rate Limiting (in-memory, upgrade to Redis later)
|
|
- CORS & Security Headers
|
|
- Health Check Endpoint (`/health`)
|
|
- Graceful Shutdown Handler
|
|
- Event system (Watermill with in-memory pubsub)
|
|
- Background workers (goroutines)
|
|
- Worker pool pattern (controlled concurrency)
|
|
|
|
**Data Layer**:
|
|
|
|
- **Neo4j**: Graph database (single instance, simple queries)
|
|
- Connection pooling configured
|
|
- Indexes: ResourceFlow.type + direction, Site.id
|
|
- **PostgreSQL + PostGIS with GORM**: Geospatial queries (synced from Neo4j)
|
|
- GORM for simpler CRUD operations
|
|
- PostGIS for spatial queries
|
|
- GIST spatial index on location
|
|
- Connection pooling configured
|
|
- **Redis**: Cache, sessions, match results
|
|
- Connection pooling configured
|
|
- Session storage for auth tokens
|
|
- **Event Processing**: Watermill with in-memory pubsub (native Go channels under the hood, zero external deps)
|
|
|
|
**Frontend**:
|
|
|
|
- **React**: Static site (Vercel)
|
|
- **Mapbox**: Map visualization
|
|
|
|
### Event Flow (Watermill - Clean Abstraction)
|
|
|
|
```go
|
|
import (
|
|
"log/slog"
|
|
"github.com/ThreeDotsLabs/watermill"
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
watermillMem "github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
|
)
|
|
|
|
// Initialize Watermill with in-memory pubsub (MVP)
|
|
// Use slog adapter for Watermill
|
|
watermillLogger := watermill.NewStdLogger(false, false)
|
|
pubsub := watermillMem.NewGoChannel(watermillMem.Config{}, watermillLogger)
|
|
|
|
// Subscribe to events
|
|
messages, err := pubsub.Subscribe(ctx, "resource_flow_created")
|
|
if err != nil {
|
|
slog.Error("failed to subscribe", "error", err)
|
|
return err
|
|
}
|
|
|
|
// Background worker with logging
|
|
go func() {
|
|
for msg := range messages {
|
|
var resourceFlow ResourceFlow
|
|
if err := json.Unmarshal(msg.Payload, &resourceFlow); err != nil {
|
|
slog.Error("failed to unmarshal event", "error", err)
|
|
msg.Nack()
|
|
continue
|
|
}
|
|
|
|
slog.Info("processing event",
|
|
"event", "resource_flow_created",
|
|
"resource_flow_id", resourceFlow.ID,
|
|
)
|
|
|
|
// Handle event
|
|
if err := handleResourceFlowCreated(ctx, resourceFlow); err != nil {
|
|
slog.Error("failed to handle event", "error", err, "resource_flow_id", resourceFlow.ID)
|
|
msg.Nack()
|
|
continue
|
|
}
|
|
|
|
slog.Info("event processed", "resource_flow_id", resourceFlow.ID)
|
|
msg.Ack()
|
|
}
|
|
}()
|
|
|
|
// In API handler with validation and logging
|
|
func createResourceFlow(c echo.Context) error {
|
|
var rf ResourceFlow
|
|
if err := c.Bind(&rf); err != nil {
|
|
slog.Warn("invalid request body", "error", err)
|
|
return echo.NewHTTPError(400, "invalid request body")
|
|
}
|
|
|
|
// Validate
|
|
if err := validate.Struct(rf); err != nil {
|
|
slog.Warn("validation failed", "error", err)
|
|
return echo.NewHTTPError(400, err.Error())
|
|
}
|
|
|
|
// Create in Neo4j with transaction
|
|
if err := createInNeo4j(ctx, rf); err != nil {
|
|
slog.Error("failed to create in Neo4j", "error", err)
|
|
return echo.ErrInternalServerError
|
|
}
|
|
|
|
slog.Info("resource flow created",
|
|
"resource_flow_id", rf.ID,
|
|
"business_id", rf.BusinessID,
|
|
)
|
|
|
|
// Publish event via Watermill
|
|
eventPayload, _ := json.Marshal(rf)
|
|
msg := message.NewMessage(watermill.NewUUID(), eventPayload)
|
|
if err := pubsub.Publish("resource_flow_created", msg); err != nil {
|
|
slog.Error("failed to publish event", "error", err)
|
|
// Continue - event will be retried or logged
|
|
}
|
|
|
|
return c.JSON(200, rf)
|
|
}
|
|
```
|
|
|
|
**Flow**:
|
|
|
|
```
|
|
User Action → API Handler → Auth → Validation → Logging → Watermill (in-memory) → Subscribers
|
|
↓
|
|
Neo4j Write (txn)
|
|
↓
|
|
PostGIS Sync (async)
|
|
↓
|
|
Match Computation
|
|
↓
|
|
Redis Cache
|
|
↓
|
|
Log Result (slog)
|
|
```
|
|
|
|
**Why Watermill**:
|
|
|
|
- Clean pubsub abstraction (not tied to specific backend)
|
|
- Start with in-memory Go channels (zero external deps)
|
|
- Easy migration to Redis Streams, Kafka, NATS (change pubsub, keep code)
|
|
- Production-ready patterns (middlewares, CQRS support)
|
|
- Well-maintained and recommended in 2025
|
|
- Perfect for MVP → production path
|
|
- Integrated with `slog` for event logging
|
|
|
|
### Deployment (Managed Services)
|
|
|
|
**Option 1: Fully Managed (Easiest)**
|
|
|
|
- **Railway/Render**: Neo4j Aura, PostgreSQL, Redis
|
|
- One-click deploy, managed backups
|
|
- Slightly higher cost but zero ops
|
|
- **No Kafka needed** - Watermill in-memory pubsub handles events
|
|
|
|
**Option 2: Self-Hosted (More Control)**
|
|
|
|
- **Single Server**: DigitalOcean Droplet ($20/month)
|
|
- Docker Compose: Neo4j, PostgreSQL, Redis
|
|
- More control, lower cost, more ops
|
|
- **No Kafka needed** - Watermill in-memory pubsub handles events
|
|
|
|
**Why Watermill (Not Kafka) for MVP**:
|
|
|
|
- Watermill with in-memory pubsub handles all event processing
|
|
- Zero external dependencies (uses Go channels under the hood)
|
|
- Clean abstraction allows easy backend switching later
|
|
- Simpler deployment and operations
|
|
- Switch to Redis Streams/Kafka/NATS only when:
|
|
- Need distributed processing (multiple servers)
|
|
- Need event persistence/replay
|
|
- Need high-throughput event streaming
|
|
- Same code, just swap the pubsub implementation
|
|
|
|
---
|
|
|
|
## MVP Development Timeline (Foundation Stack)
|
|
|
|
### Week 1: Foundation Setup
|
|
|
|
- Neo4j setup (schema, basic nodes/relationships, indexes)
|
|
- PostgreSQL + PostGIS setup (GIST spatial index)
|
|
- Redis setup (connection pooling)
|
|
- Go project structure
|
|
- Environment configuration (`.env` files, environment variables)
|
|
- Structured logging setup (`log/slog`: JSON for prod, text for dev)
|
|
- Error handling framework (standardized error responses)
|
|
- Connection pooling configuration (Neo4j, PostgreSQL, Redis)
|
|
|
|
### Week 2: Core Backend + Security
|
|
|
|
- Go API with Echo
|
|
- JWT authentication (middleware, token generation)
|
|
- Authorization (RBAC: Business Owner, Public)
|
|
- Input validation (`go-playground/validator`)
|
|
- Error handling middleware
|
|
- Request logging middleware (`slog`)
|
|
- Neo4j driver integration (with connection pooling)
|
|
- PostgreSQL + PostGIS with GORM integration (with connection pooling)
|
|
- Health check endpoint (`/health`)
|
|
- Rate limiting middleware (basic in-memory, upgrade to Redis later)
|
|
- CORS configuration
|
|
- Security headers middleware
|
|
|
|
### Week 3: Event Processing + Data Layer
|
|
|
|
- Event system with Watermill (in-memory pubsub)
|
|
- Watermill subscribers setup
|
|
- Event handlers (Neo4j write, PostGIS sync)
|
|
- Background match computation (Watermill subscribers)
|
|
- Message routing and error handling
|
|
- Graceful shutdown handler (SIGTERM/SIGINT)
|
|
- Database transaction management
|
|
- Basic matching algorithm (all 3 steps)
|
|
- Redis caching layer
|
|
|
|
### Week 4: Frontend
|
|
|
|
- React app setup
|
|
- Business registration form (with validation)
|
|
- Login/authentication flow
|
|
- Match results page
|
|
- Map view (Mapbox)
|
|
- Real-time updates (polling initially, WebSocket later)
|
|
- Error handling (display error messages)
|
|
|
|
### Week 5: Integration & Testing
|
|
|
|
- End-to-end integration
|
|
- Unit tests (matching algorithm, validation logic)
|
|
- Integration tests (API endpoints, database operations)
|
|
- E2E test (business registration → match flow)
|
|
- Email notifications
|
|
- Contact request flow
|
|
- Testing with seed data
|
|
- Bug fixes
|
|
|
|
### Week 6: Launch Prep + Monitoring
|
|
|
|
- Production deployment
|
|
- Monitoring setup (basic metrics, health checks)
|
|
- Log aggregation (configure logging output)
|
|
- Documentation (API docs, deployment guide)
|
|
- Marketing materials
|
|
- Final testing and bug fixes
|
|
|
|
### Week 7: Polish & Launch (Buffer)
|
|
|
|
- Performance testing
|
|
- Security audit (basic)
|
|
- Load testing (simple)
|
|
- Documentation polish
|
|
- Marketing materials finalization
|
|
- Launch!
|
|
|
|
**Total: ~7-8 weeks to MVP launch** (includes production-ready foundations)
|
|
|
|
---
|
|
|
|
## MVP Risks & Mitigations
|
|
|
|
### Risk 1: Not Enough Businesses
|
|
|
|
**Mitigation**:
|
|
|
|
- Target single area (industrial park)
|
|
- Seed with 10 businesses yourself
|
|
- Offer free access initially
|
|
|
|
### Risk 2: No Matches Found
|
|
|
|
**Mitigation**:
|
|
|
|
- Start with larger geographic radius (10km)
|
|
- Focus on common resources (heat, water)
|
|
- Expand matching criteria
|
|
|
|
### Risk 3: Businesses Don't Engage
|
|
|
|
**Mitigation**:
|
|
|
|
- Simple interface (5-minute setup)
|
|
- Clear value proposition (€X savings)
|
|
- Automated email reminders
|
|
|
|
### Risk 4: Technical Complexity
|
|
|
|
**Mitigation**:
|
|
|
|
- Use PostgreSQL (not Neo4j initially)
|
|
- Keep algorithm simple
|
|
- Launch fast, iterate based on feedback
|
|
|
|
---
|
|
|
|
## MVP to Full Product Path (Vertical Scaling)
|
|
|
|
### Foundation is Built - Now Scale Each Layer
|
|
|
|
### Phase 1: Enhance Matching (100 businesses)
|
|
|
|
- **Current**: Simple 3-step matching
|
|
- **Enhance**:
|
|
- Add temporal overlap checking (time profiles)
|
|
- Improve economic scoring (better cost models)
|
|
- Add quality compatibility matrix
|
|
- **No migration needed**: Enhance Go algorithm
|
|
|
|
### Phase 2: Complex Graph Queries (500 businesses)
|
|
|
|
- **Current**: Simple Cypher queries
|
|
- **Enhance**:
|
|
- Multi-hop graph traversals (find clusters)
|
|
- Complex relationship patterns
|
|
- Graph-based clustering algorithms
|
|
- **No migration needed**: Write more complex Cypher
|
|
|
|
### Phase 3: Advanced Matching (1000 businesses)
|
|
|
|
- **Current**: Simple economic scoring
|
|
- **Enhance**:
|
|
- MILP optimization for multi-party matches
|
|
- Genetic algorithms for clustering
|
|
- Machine learning for match ranking
|
|
- **No migration needed**: Add optimization service
|
|
|
|
### Phase 4: Distributed Processing (1000+ businesses, multiple servers)
|
|
|
|
- **Current**: Watermill in-memory pubsub (single server)
|
|
- **Enhance**:
|
|
- Replace in-memory pubsub with Redis Streams or Kafka pubsub
|
|
- Multiple worker servers
|
|
- Event persistence and replay
|
|
- **Migration**: Swap Watermill pubsub implementation (same code, different backend)
|
|
|
|
### Phase 5: Multi-Region (Enterprise)
|
|
|
|
- **Current**: Single region
|
|
- **Enhance**:
|
|
- Graph federation (multiple Neo4j instances)
|
|
- Cross-region matching
|
|
- Regional data residency
|
|
- **No migration needed**: Add federation layer
|
|
|
|
### Migration Strategy: Vertical Enhancement
|
|
|
|
- ✅ **Neo4j**: Simple queries → Complex traversals (same DB)
|
|
- ✅ **PostGIS**: Basic distance → Advanced spatial operations (same DB)
|
|
- ✅ **Events**: Watermill in-memory → Watermill Redis Streams/Kafka (same code, swap pubsub)
|
|
- ✅ **Matching**: Simple algorithm → MILP optimization (same service)
|
|
- ✅ **Workers**: Single server → Multiple servers (same worker pattern)
|
|
|
|
**Key Advantage**: Foundation stays, we enhance each layer independently
|
|
|
|
---
|
|
|
|
## MVP Sales Pitch (1-Minute Version)
|
|
|
|
> "We help local businesses save money by finding resource matches. For example, if you emit waste heat, we find nearby businesses that need heat. One connection can save €10k-50k per year. It's free to join—we only get paid if you save money. Takes 5 minutes to list your business. Want to see if we have matches in your area?"
|
|
|
|
---
|
|
|
|
## Key Principles for Horizontal MVP
|
|
|
|
1. **Right Foundation**: All architectural layers present (Neo4j, PostGIS, Redis)
|
|
2. **Go Native First**: Use Watermill with in-memory pubsub (zero external deps)
|
|
3. **Keep Each Simple**: Simple queries, simple algorithms, simple setup
|
|
4. **Scale Vertically**: Enhance each layer independently, no rewrites
|
|
4. **Focus on One Thing**: Heat matching only (not all resources)
|
|
5. **One Geography**: One industrial park (not global)
|
|
6. **Manual First**: Web forms (not IoT) for data entry
|
|
7. **Clear Value**: €X savings per year (not abstract concepts)
|
|
8. **Low Friction**: 5-minute signup, free to try
|
|
9. **Network Effects**: More users = better matches
|
|
10. **Success-Based Pricing**: Only pay if you save money
|
|
|
|
**Horizontal MVP Philosophy**:
|
|
|
|
- Build complete foundation now (all layers)
|
|
- Use Go-native patterns first (Watermill in-memory pubsub)
|
|
- Keep each layer simple initially
|
|
- Add complexity to each layer as needed (vertical scaling)
|
|
- Avoid architectural rewrites (horizontal changes)
|
|
- Replace Watermill in-memory pubsub with Redis/Kafka pubsub only when needed (distributed processing)
|
|
|
|
**Event Processing Pattern (Watermill)**:
|
|
|
|
```go
|
|
import (
|
|
"github.com/ThreeDotsLabs/watermill"
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
watermillMem "github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
|
// Later: watermillRedis "github.com/ThreeDotsLabs/watermill/pubsub/redis/v2"
|
|
// Later: watermillKafka "github.com/ThreeDotsLabs/watermill/pubsub/kafka"
|
|
)
|
|
|
|
// MVP: In-memory pubsub (uses Go channels under the hood)
|
|
logger := watermill.NewStdLogger(false, false)
|
|
pubsub := watermillMem.NewGoChannel(watermillMem.Config{}, logger)
|
|
|
|
// Later: Swap to Redis Streams (same code, different initialization)
|
|
// redisClient, _ := redis.NewClient(...)
|
|
// pubsub := watermillRedis.NewPublisher(redisClient, watermillRedis.PublisherConfig{}, logger)
|
|
|
|
// Later: Swap to Kafka (same code, different initialization)
|
|
// saramaConfig := sarama.NewConfig()
|
|
// pubsub := watermillKafka.NewPublisher(watermillKafka.PublisherConfig{}, logger)
|
|
|
|
// Same API for all backends:
|
|
pubsub.Publish("resource_flow_created", msg)
|
|
messages, _ := pubsub.Subscribe(ctx, "resource_flow_created")
|
|
```
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
1. **Validate Concept**: Talk to 10 businesses in target area
|
|
2. **Build MVP**: 5-week sprint to launch
|
|
3. **Seed Users**: Get 20 businesses signed up
|
|
4. **Find First Match**: Document success story
|
|
5. **Market & Grow**: Use success story to acquire more users
|
|
6. **Iterate**: Add features based on user feedback
|
|
|
|
**Goal**: Sellable MVP with proper foundation in 7-8 weeks, revenue in 3 months.
|
|
|
|
**Updated Timeline**: Includes production-ready foundations (auth, validation, logging, error handling, graceful shutdown).
|
|
|
|
**Foundation Benefits**:
|
|
|
|
- No expensive migrations later
|
|
- Can scale each layer independently
|
|
- Right architecture from day 1
|
|
- Easy to add complexity without rewrites
|