Remove 557-line server-design.md from _go-learning/architecture - content is now covered in updated architecture documentation with real implementation examples and test coverage.
28 KiB
Refactoring #001: CV Model and UI Model Separation
Date: 2025-11-20 Status: In Progress Complexity: Medium Learning Value: ⭐⭐⭐⭐⭐
📋 Table of Contents
- The Problem
- Why This Matters
- The Solution
- Deep Dive: Go Package Philosophy
- Architecture Diagrams
- Implementation Steps
- Testing Strategy
- Lessons Learned
🔴 The Problem
Current State
The file internal/models/cv.go (301 lines) contains two completely different concerns:
-
Domain Models (lines 13-158): Business logic and CV data structures
CV,Personal,Experience,Education,Skills,Language,Project, etc.- Represents the core business domain of a curriculum vitae
- Data that comes from
data/cv-{lang}.json
-
Presentation Models (lines 160-215): UI configuration and translations
UI,InfoModal,ShortcutsModal,TechStack,ShortcutGroup, etc.- Represents user interface state and internationalization
- Data that comes from
data/ui-{lang}.json
Why Is This a Problem?
// Current: Everything mixed together
package models
type CV struct { ... } // Business domain
type Experience struct { ... } // Business domain
type UI struct { ... } // Presentation layer!?
type InfoModal struct { ... } // Presentation layer!?
Violations:
- ❌ Single Responsibility Principle: One file doing two jobs
- ❌ Separation of Concerns: Business logic mixed with UI logic
- ❌ Scalability: Hard to grow either domain independently
- ❌ Testability: Can't test CV logic without UI types in scope
- ❌ Clarity: New developers confused about boundaries
🎯 Why This Matters
1. Separation of Concerns (Fundamental Design Principle)
"A module should be responsible to one, and only one, actor." - Robert C. Martin (Uncle Bob)
In our case, we have two actors:
- Business stakeholders: Care about CV data structure, validation, completeness
- UI/UX designers: Care about modal content, keyboard shortcuts, translations
Mixing these concerns means:
- Changes to UI translations force recompilation of CV business logic
- Testing CV data loading requires UI types in memory
- Can't reason about one domain without understanding the other
2. Go's Package Philosophy
Go encourages small, focused packages that do one thing well:
// Go standard library examples
import "net/http" // HTTP client/server
import "encoding/json" // JSON encoding
import "html/template" // HTML templating
// NOT like this (anti-pattern):
import "net/everything" // HTTP, WebSocket, RPC, all mixed
Key Insight: Go packages are the primary means of abstraction. Unlike Java/C# where classes are primary, in Go you think in packages.
3. Dependency Management
Current (Bad):
┌─────────────────────────────────┐
│ internal/handlers/cv.go │
│ (imports "models" - gets BOTH │
│ CV domain AND UI presentation)│
└─────────────────────────────────┘
▼
┌─────────────────────────────────┐
│ internal/models/cv.go │
│ CV domain + UI presentation │
│ (300+ lines, mixed concerns) │
└─────────────────────────────────┘
Future (Good):
┌─────────────────────────────────┐
│ internal/handlers/cv.go │
│ (imports BOTH packages, but │
│ can choose what to import) │
└─────────────────────────────────┘
▼ ▼
┌──────────┐ ┌──────────┐
│ models/cv│ │ models/ui│
│ (domain)│ │ (present)│
└──────────┘ └──────────┘
Benefits:
- Handlers can import just
models/cvif they don't need UI - PDF generator doesn't need to know about modals
- API endpoints can return CV data without UI overhead
4. Testing Independence
// With separated packages, you can test CV logic without UI:
// cv/loader_test.go
func TestLoadCV(t *testing.T) {
cv, err := cv.LoadCV("en")
// Test ONLY CV business logic
// No UI types needed, faster compilation
}
// ui/loader_test.go
func TestLoadUI(t *testing.T) {
ui, err := ui.LoadUI("en")
// Test ONLY UI translations
// No CV types needed
}
5. Scalability
As the project grows:
CV domain might add:
- Validation logic
- Export formats (PDF, Word, LinkedIn)
- Version control
- Analytics
- Recommendations engine
UI domain might add:
- More modals
- Theme configurations
- Accessibility settings
- User preferences
- A/B testing configs
With separation: Each grows independently
Without separation: 1000+ line monolithic file
💡 The Solution
Target Package Structure
internal/models/
├── cv/ # CV Domain Package
│ ├── cv.go # Core CV types (CV, Personal, etc.)
│ ├── loader.go # LoadCV() + data loading logic
│ └── loader_test.go # Unit tests for CV loading
│
├── ui/ # UI Presentation Package
│ ├── ui.go # UI types (UI, InfoModal, etc.)
│ ├── loader.go # LoadUI() + data loading logic
│ └── loader_test.go # Unit tests for UI loading
│
└── cv.go (DEPRECATED) # Optional compatibility layer
Why This Structure?
1. Package Names Reveal Intent
import "github.com/juanatsap/cv-site/internal/models/cv"
import "github.com/juanatsap/cv-site/internal/models/ui"
Just from the import, you know:
cv= Business domain logicui= Presentation layer logic
2. File Names Are Self-Documenting
cv/loader.go → "This loads CV data"
ui/loader.go → "This loads UI translations"
No need to read 300 lines to find what you need.
3. Tests Live Alongside Code (Go Convention)
cv/cv.go → CV type definitions
cv/loader.go → CV loading logic
cv/loader_test.go → Tests for loader.go
Go's tooling expects *_test.go files in the same directory as the code they test.
🏗️ Deep Dive: Go Package Philosophy
What Makes a Good Go Package?
Principle 1: Cohesion
"Things that change together should be packaged together."
Good: All CV types in one package (they change when business requirements change) Bad: CV types mixed with UI types (they change for different reasons)
Principle 2: Minimal API Surface
"Export only what's necessary."
// cv/loader.go
// Exported (public API)
func LoadCV(lang string) (*CV, error) { ... }
// Unexported (internal helper)
func findDataFile(filename string) (string, error) { ... }
func replaceYearPlaceholder(url, year string) string { ... }
Why unexport helpers?
- Smaller public API = easier to maintain
- Can refactor internals without breaking clients
- Forces callers to use the high-level
LoadCV()interface
Principle 3: No Circular Dependencies
Go compiler forbids package cycles:
// This will NOT compile:
package cv
import "ui" // cv depends on ui
package ui
import "cv" // ui depends on cv
// ERROR: import cycle not allowed
Our design avoids this:
handlers → cv
handlers → ui
cv → (nothing)
ui → (nothing)
Clean one-way dependency flow!
Principle 4: Package Names Are Part of the API
// BAD: Stutter
cv.CVLoader() // "CV" mentioned twice
cv.LoadCVData() // Redundant
// GOOD: Clear, concise
cv.LoadCV() // Package name + function name = clear intent
ui.LoadUI() // Same pattern
When you import cv, it's obvious everything in it relates to CV domain.
📊 Architecture Diagrams
Before Refactoring (Current State)
┌─────────────────────────────────────────────────────────────┐
│ main.go │
│ (Server startup) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ internal/handlers/cv.go │
│ (HTTP handlers for CV endpoints) │
│ │
│ import "github.com/.../internal/models" │
│ │
│ cv, _ := models.LoadCV("en") ← Gets CV data │
│ ui, _ := models.LoadUI("en") ← Gets UI translations │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ internal/models/cv.go │
│ (300+ LINES) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ CV Domain Models (lines 13-158) │ │
│ │ - type CV struct { ... } │ │
│ │ - type Personal struct { ... } │ │
│ │ - type Experience struct { ... } │ │
│ │ - func LoadCV(lang) (*CV, error) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ UI Presentation Models (lines 160-215) │ │
│ │ - type UI struct { ... } │ │
│ │ - type InfoModal struct { ... } │ │
│ │ - type ShortcutsModal struct { ... } │ │
│ │ - func LoadUI(lang) (*UI, error) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ PROBLEM: Two concerns mixed in one file! │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ data/cv-*.json│ │data/ui-*.json│
│ (CV data) │ │ (UI text) │
└──────────────┘ └──────────────┘
Issues:
- ❌ Single file with multiple responsibilities
- ❌ Can't import CV without also importing UI types
- ❌ Hard to test CV logic in isolation
- ❌ Confusing for new developers
After Refactoring (Target State)
┌─────────────────────────────────────────────────────────────┐
│ main.go │
│ (Server startup) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ internal/handlers/cv.go │
│ (HTTP handlers for CV endpoints) │
│ │
│ import cvmodel "github.com/.../internal/models/cv" │
│ import uimodel "github.com/.../internal/models/ui" │
│ │
│ cv, _ := cvmodel.LoadCV("en") ← Clear: CV domain │
│ ui, _ := uimodel.LoadUI("en") ← Clear: UI presentation │
└─────────────────────────────────────────────────────────────┘
│ │
┌──────────┴────────┐ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────────┐
│ internal/models/│ │ internal/models/ui/ │
│ cv/ │ │ │
│ │ │ ┌───────────────────┐ │
│ ┌─────────────┐ │ │ │ ui.go │ │
│ │ cv.go │ │ │ │ - type UI │ │
│ │ - type CV │ │ │ │ - type InfoModal │ │
│ │ - Personal │ │ │ │ - type Shortcuts │ │
│ │ - Experience│ │ │ └───────────────────┘ │
│ └─────────────┘ │ │ │
│ │ │ ┌───────────────────┐ │
│ ┌─────────────┐ │ │ │ loader.go │ │
│ │ loader.go │ │ │ │ - LoadUI(lang) │ │
│ │ - LoadCV() │ │ │ │ - findDataFile() │ │
│ └─────────────┘ │ │ └───────────────────┘ │
│ │ │ │
│ ┌─────────────┐ │ │ ┌───────────────────┐ │
│ │loader_test │ │ │ │ loader_test.go │ │
│ │ - TestLoadCV│ │ │ │ - TestLoadUI │ │
│ └─────────────┘ │ │ └───────────────────┘ │
└─────────────────┘ └─────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│data/cv-*.json│ │data/ui-*.json│
│ (CV data) │ │ (UI text) │
└──────────────┘ └──────────────┘
Benefits:
- ✅ Clear separation of concerns
- ✅ Can import
cvwithoutui(and vice versa) - ✅ Independent testing of each domain
- ✅ Easier to navigate and understand
- ✅ Scales as project grows
Dependency Graph
Templates
(*.html)
▲
│ (runtime reflection,
│ no compile-time deps)
│
┌────────┴────────┐
│ │
internal/handlers/cv.go │
│ │
│ │
┌─────────┴─────────┐ │
│ │ │
▼ ▼ │
┌─────────┐ ┌──────────┐ │
│models/cv│ │models/ui │ │
│ │ │ │ │
│ (domain)│ │(present) │ │
└─────────┘ └──────────┘ │
│ │ │
└─────────┬─────────┘ │
▼ │
JSON Data Files │
┌──────────────┐ │
│ cv-*.json │ │
│ ui-*.json │ │
└──────────────┘ │
│
│
Static Assets ────┘
(CSS, images)
Key Observations:
- No circular dependencies
cvanduipackages are independent (parallel)- Handlers orchestrate both domains
- Templates have no compile-time dependencies
🔧 Implementation Steps
Phase 1: Create New Package Structure ✅
mkdir -p internal/models/cv
mkdir -p internal/models/ui
Phase 2: Extract CV Domain Types ✅
File: internal/models/cv/cv.go
- Move:
CV,Personal,Experience,Education,Skills,SkillCategory,Language,Project,Award,Certification,Course,Reference,Other,Meta - Keep: JSON tags, struct tags, comments
File: internal/models/cv/loader.go
- Move:
LoadCV()function - Move:
findDataFile()helper - Move:
replaceYearPlaceholder()helper - Add: Package-level documentation
Phase 3: Extract UI Presentation Types ✅
File: internal/models/ui/ui.go
- Move:
UI,InfoModal,TechStack,ShortcutsModal,ShortcutsSections,ShortcutGroup,ShortcutItem
File: internal/models/ui/loader.go
- Move:
LoadUI()function - Duplicate:
findDataFile()helper (or create shared util)
Phase 4: Update Handler Imports 🔄
File: internal/handlers/cv.go
// Before:
import "github.com/juanatsap/cv-site/internal/models"
// After:
import (
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
)
Why the aliases?
cvmodelanduimodelprevent shadowing ofcvanduivariables- Common Go pattern when package name conflicts with variable names
Phase 5: Create Tests ✅
File: internal/models/cv/loader_test.go
- Test
LoadCV()for both languages - Test error cases (invalid language, missing file)
- Test year placeholder replacement
File: internal/models/ui/loader_test.go
- Test
LoadUI()for both languages - Test error cases
Phase 6: Validation 🔄
# Compile check
go build ./...
# Run tests
go test ./internal/models/cv/...
go test ./internal/models/ui/...
# Start server
make run
# Test endpoints
curl http://localhost:1999/?lang=en
curl http://localhost:1999/?lang=es
🧪 Testing Strategy
Test Organization
Frontend Tests (EXISTING - DON'T TOUCH):
tests/
├── mjs/
│ ├── 01-*.test.mjs # E2E tests
│ ├── 29-pdf-toast-*.test.mjs
│ └── ...
└── (Bun/JavaScript tests for full UX)
Backend Tests (NEW - GO TESTS):
internal/
├── models/
│ ├── cv/
│ │ ├── cv.go
│ │ ├── loader.go
│ │ └── loader_test.go # Unit tests for CV package
│ └── ui/
│ ├── ui.go
│ ├── loader.go
│ └── loader_test.go # Unit tests for UI package
└── handlers/
├── cv.go
└── cv_test.go # Integration tests for handlers
Test Types
1. Unit Tests (Fast, Isolated)
// internal/models/cv/loader_test.go
func TestLoadCV_ValidLanguage(t *testing.T) {
cv, err := LoadCV("en")
if err != nil {
t.Fatalf("LoadCV failed: %v", err)
}
if cv.Personal.Name == "" {
t.Error("Expected CV to have a name")
}
}
Purpose: Test individual functions in isolation
2. Integration Tests (Medium Speed)
// internal/handlers/cv_test.go
func TestHomeHandler_ReturnsCV(t *testing.T) {
req := httptest.NewRequest("GET", "/?lang=en", nil)
w := httptest.NewRecorder()
handler.Home(w, req)
if w.Code != 200 {
t.Errorf("Expected 200, got %d", w.Code)
}
}
Purpose: Test how components work together
3. E2E Tests (Existing, Don't Touch)
// tests/mjs/01-*.test.mjs
test('CV loads in English', async () => {
await page.goto('http://localhost:1999/?lang=en');
await expect(page.locator('h1')).toContainText('CV');
});
Purpose: Test full user experience (UI + backend + interactions)
Why This Separation?
| Test Type | Speed | Scope | When to Run |
|---|---|---|---|
| Go Unit | ⚡ Fast (ms) | Single function | Every save, pre-commit |
| Go Integration | 🏃 Medium (100ms) | Multiple components | Pre-push, CI |
| E2E Frontend | 🐌 Slow (seconds) | Full application | Pre-deploy, nightly |
Philosophy:
- Unit tests: Catch bugs early, run constantly
- Integration tests: Verify components work together
- E2E tests: Ensure user experience is intact
📚 Lessons Learned
1. Go Package Design Is an Art
Creating packages isn't about file organization—it's about domain boundaries.
Bad approach: "Let's split cv.go because it's too long" Good approach: "Let's separate CV domain from UI domain because they have different responsibilities"
2. Duplication vs. Abstraction
We duplicated findDataFile() in both cv/loader.go and ui/loader.go.
Why?
- It's a small function (17 lines)
- Creates package independence (no shared dependencies)
- Avoids premature abstraction
Rule of thumb: "Duplication is far cheaper than the wrong abstraction." - Sandi Metz
3. Import Aliases Save Pain
// Without aliases (BAD):
import "github.com/.../models/cv"
func Home() {
cv, _ := cv.LoadCV("en") // ERROR: cv.cv? Confusing!
}
// With aliases (GOOD):
import cvmodel "github.com/.../models/cv"
func Home() {
cv, _ := cvmodel.LoadCV("en") // Clear!
}
4. Tests Are Part of the Public API
In Go, tests live alongside the code. This forces you to think about:
- What's exported (public)?
- What's unexported (private)?
- How will clients use this package?
5. Refactoring Is Iterative
We could further refactor:
- Split
cv/cv.gointo multiple files (personal.go,experience.go, etc.) - Extract validation logic
- Add builder patterns
But we stopped here because:
- ✅ Addresses the immediate problem (mixed concerns)
- ✅ Provides clear boundaries
- ✅ Leaves room for future improvements
- ✅ Doesn't over-engineer
🎓 Key Takeaways for Job Interviews
When Asked About Go Package Design:
Question: "How do you organize Go code?"
Answer Framework:
- Start with the domain: What are the core concepts? (CV, UI, etc.)
- Identify responsibilities: What changes together? What changes for different reasons?
- Create boundaries: Packages represent domain boundaries, not file organization
- Follow Go idioms: Small, focused packages with clear names
- Avoid circular dependencies: Design for one-way dependency flow
Example Response:
"In my CV project, I refactored a monolithic
models/cv.gofile that mixed business domain (CV data) with presentation logic (UI translations). I split it into two packages:models/cvfor the business domain andmodels/uifor presentation.This followed Go's philosophy of small, focused packages and made the code more testable—I could now test CV logic without importing UI types. The key insight was recognizing these were two different domains with different change drivers: CV structure changes when business requirements change, while UI changes when designers update the interface.
I also ensured no circular dependencies by having both packages be leaf nodes in the dependency graph, with handlers orchestrating both."
When Asked About Refactoring:
Question: "Tell me about a significant refactoring you did."
Answer Framework:
- Identify the problem: What was wrong? Why did it matter?
- Design the solution: What principles guided your approach?
- Execute incrementally: How did you minimize risk?
- Validate the change: How did you ensure it worked?
- Measure the impact: What improved?
Metrics for this refactoring:
- Lines per file: 300+ → ~100 (more manageable)
- Test isolation: Impossible → Easy (independent domains)
- Compilation time: Unchanged (actually slightly faster with parallel compilation)
- Maintainability: Improved (clear boundaries)
📈 Next Steps
Potential Future Improvements
-
Further file splitting:
cv/ ├── cv.go # Core CV type ├── personal.go # Personal info types ├── experience.go # Work experience types ├── skills.go # Skills types └── loader.go # Data loading -
Add validation:
// cv/validation.go func (cv *CV) Validate() error { if cv.Personal.Name == "" { return errors.New("name is required") } // ... } -
Introduce interfaces:
type CVRepository interface { LoadCV(lang string) (*CV, error) SaveCV(cv *CV, lang string) error } -
Add builders:
cv := cv.NewBuilder(). WithPersonal(personal). WithExperience(experiences). Build()
But remember: Don't over-engineer. Add complexity only when you need it.
📖 Further Reading
Go Package Design
Software Design Principles
- Single Responsibility Principle (SRP)
- Separation of Concerns (SoC)
- Dependency Inversion Principle (DIP)
Books
- "The Go Programming Language" - Donovan & Kernighan (Chapter 10: Packages)
- "Clean Architecture" - Robert C. Martin (Principles apply to Go)
Last Updated: 2025-11-20 Status: Implementing Confidence: High (well-established patterns)