The Hook
Not every challenge needs a database, a design system, or a microservice to be worth doing well. Veritas Consultoria Empresarial's Full Stack Challenge asked for a Mini Kanban — React frontend, Go backend, in-memory storage allowed. Simple scope, on purpose.
I used that room to practice the decisions that actually separate a demo from a delivery. Here's what's in the repo.
Isolating Storage Before You Need To
In-memory storage was explicitly allowed by the challenge. The lazy version is a global map somewhere in a handler file. Instead, every read and write goes through a MemoryTaskRepository:
type MemoryTaskRepository struct {
mu sync.RWMutex
tasks map[string]domain.Task
}
func (r *MemoryTaskRepository) List() []domain.Task {
r.mu.RLock()
defer r.mu.RUnlock()
tasks := make([]domain.Task, 0, len(r.tasks))
for _, task := range r.tasks {
tasks = append(tasks, task)
}
sort.SliceStable(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
})
return tasks
}
func (r *MemoryTaskRepository) Save(task domain.Task) {
r.mu.Lock()
defer r.mu.Unlock()
r.tasks[task.ID] = task
}
Two things worth unpacking:
Why sync.RWMutex? Go's net/http server runs every request in its own goroutine, and plain maps aren't safe for concurrent access. RWMutex lets multiple reads happen in parallel (RLock) while writes get serialized (Lock) — so two simultaneous requests can't race and corrupt the map.
Why in-memory at all? Because it's behind a Repository interface, swapping the map for Postgres later means implementing one new struct with the same methods — List, FindByID, Save, Delete — not touching every handler in the codebase. The README documents this explicitly as a scope trade-off, data resets on restart, on purpose: it's scope, not an oversight.
Validation Lives With the Data, Not the Handler
func NewTask(id, title, description string, status TaskStatus) (*Task, error) {
task := &Task{
ID: id,
Title: strings.TrimSpace(title),
Description: strings.TrimSpace(description),
Status: status,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := task.Validate(); err != nil {
return nil, err
}
return task, nil
}
func (t *Task) Validate() error {
t.Title = strings.TrimSpace(t.Title)
t.Description = strings.TrimSpace(t.Description)
if t.Title == "" {
return ErrTaskTitleRequired
}
if !t.Status.IsValid() {
return ErrInvalidTaskStatus
}
return nil
}
Both creation and update route through the same Validate(). Trim, required-field check, status check — one place, applied consistently no matter which code path touches a Task. On the HTTP side, the decoder adds its own layer: DisallowUnknownFields() rejects payloads with unexpected fields instead of silently ignoring them.
Two Paths, One Handler
Drag-and-drop feels like the whole feature — until you think about who can't use a mouse. Instead of treating keyboard/touch as an afterthought, TaskCard supports both from the start:
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(task);
}
}
return (
<article
className="task-card"
role="button"
tabIndex={0}
draggable
aria-label={`Abrir tarefa: ${task.title}`}
onClick={handleOpen}
onKeyDown={handleKeyDown}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
Tab focuses the card, Enter/Space opens it, and the form inside lets you change status without ever touching drag-and-drop. Both paths — mouse drag and keyboard/form — end up calling the exact same handleUpdateTask in the useTasks hook. The update logic has no idea, and doesn't need to know, which path triggered it:
async function handleUpdateTask(id: string, input: UpdateTaskInput) {
try {
setIsSubmitting(true);
const updatedTask = await updateTask(id, input);
setTasks((currentTasks) =>
currentTasks.map((task) => (task.id === id ? updatedTask : task)),
);
setSuccessMessage(getTaskUpdatedMessage(previousTask, updatedTask));
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível atualizar a tarefa.");
return false;
} finally {
setIsSubmitting(false);
}
}
What Shipped
- ✅ Full CRUD wired to a REST API
- ✅ Drag-and-drop between columns
- ✅ Validation on frontend and backend
- ✅ Loading, error, and feedback states
- ✅ Keyboard navigation + accessible alternative to drag-and-drop
- ✅ Backend organized into domain / handlers / repository
- ✅ Backend tests (repository + domain)
- ✅ One-command Docker Compose setup
- ✅ README, User Flow, and Data Flow documented with Mermaid diagrams
The Actual Takeaway
None of this — the Repository interface, the documented trade-offs, the accessible alternative — was required to pass a demo. A hardcoded array and a console.log would've worked for the happy path. What pushed these decisions was thinking about who reads this code next: whoever tests it, runs it, extends it, or has to explain it out loud in an interview.
Thanks to Veritas Consultoria Empresarial for turning a selection process into an actual practice exercise.
Repo: https://github.com/luis-botelho/desafio-fullstack-veritas
Let's Discuss! 👇
What's a "small" technical challenge where you ended up over-engineering (in a good way) just to practice a decision you don't get to make often?


Top comments (0)