How to Nail a Take-Home Coding Assignment (Step-by-Step)
Take-home coding assignments are one of the most misunderstood parts of the technical interview process. Candidates treat them like a speed run — crank out the feature, zip it up, send it off. Then they wonder why they got rejected despite the code "working."
This guide covers every stage of a take-home assignment: reading the brief, making architecture decisions, writing clean code, testing, documenting, and submitting — with real code examples at each step.
By the end, you will know exactly how senior engineers approach these assignments and why most candidates fail despite getting the code to run.
PART 1: UNDERSTANDING THE ASSIGNMENT
1. What is a take-home coding assignment, and why do companies use them?
A take-home assignment is a scoped engineering task you complete on your own time, usually within 24–72 hours. Companies use them because live coding creates performance anxiety and filters out people who think well but freeze under pressure. Take-homes show your actual working style: how you structure a project, what you test, what you document, and what tradeoffs you make.
The output being evaluated is not just whether the code runs. Reviewers look at:
- Code organization and naming
- Presence and quality of tests
- How you handle edge cases
- Your README and communication
- Whether you made intentional architectural decisions
2. How do I read the brief properly before writing a single line of code?
Read the brief three times before touching your keyboard.
First read: get the big picture. What is the feature? Who is the user?
Second read: underline every verb. "Create," "update," "list," "filter" — these are your endpoints or functions. Underline every noun. "User," "order," "product" — these are your data models.
Third read: look for what is NOT said. Pagination? Authentication? Error responses? Rate limits? These omissions are not accidents. Either they are out of scope, or the interviewer wants to see if you notice.
A brief that says "build a REST API for a task manager" leaves open:
- Whether tasks belong to a user
- Whether you need auth
- What happens when you delete a user with tasks
- What format errors should return
Note every assumption you make. You will document them.
3. Should I ask clarifying questions or just make assumptions?
Ask one focused clarifying question if there is genuine ambiguity that would change your architecture — for example, "Should tasks be scoped per user, or is this a shared list?" Do not ask five questions about spacing or naming conventions. That signals insecurity.
For everything else, make a decision and document it. A README that says "I assumed tasks belong to a user and require authentication; if this is wrong, the auth middleware in src/middleware/auth.ts can be removed" shows engineering maturity far better than a flood of emails.
4. How much time should I budget for the assignment?
Rule of thumb: if they say "3 hours," the actual deliverable quality they expect takes someone who knows what they are doing about 3 hours. That person is not rushing — they already know their patterns.
Suggested time split:
| Phase | % of total time |
|---|---|
| Reading brief + planning | 10% |
| Core implementation | 45% |
| Tests | 25% |
| Documentation + cleanup | 15% |
| Submission review | 5% |
Do not skip the last 5%. Reading your own submission cold is the single most impactful quality step.
5. What if the deadline is too short to do it properly?
Submit what you have with a clear note: "Given the time constraint, I prioritized [X] and [Y]. With more time I would have added [Z] — here is how I would approach it." That honesty scores better than silent corners cut.
PART 2: PLANNING AND ARCHITECTURE
6. How should I structure my project before writing any code?
Sketch your data model first. Then your API surface or component tree. Then your file structure. In that order.
For a backend project:
src/
models/ # data types / DB schema
services/ # business logic (pure, testable)
controllers/ # HTTP layer — thin, delegates to services
middleware/ # auth, error handling, logging
utils/ # pure helpers
__tests__/ # mirrors src structureFor a frontend project:
src/
components/ # reusable UI
pages/ # route-level components
hooks/ # custom React hooks
services/ # API calls
utils/ # pure helpers
__tests__/The principle is the same: keep business logic away from delivery mechanisms. A service function that creates a task should not know whether it is being called from an HTTP endpoint or a test.
7. Should I use a framework or start from scratch?
Use a framework. Take-homes are not the place to prove you can build Express from scratch. Use what the job uses if they specify it. If they do not, use the standard tool for the language:
- Node.js: Express or Fastify
- Python: FastAPI or Flask
- Ruby: Rails or Sinatra
- Go: standard
net/httpor Gin
The exception: if the brief says "no frameworks" or names a specific constraint. Then follow it exactly.
8. What database should I use for a take-home?
Unless specified, use SQLite with a good ORM. It requires zero infrastructure setup, the reviewer can run it with one command, and it demonstrates real SQL thinking. PostgreSQL is fine if you containerize it with Docker Compose — but add clear setup instructions. Never require the reviewer to manually create a database.
Example docker-compose.yml if using Postgres:
version: "3.8"
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: taskmanager
ports:
- "5432:5432"
app:
build: .
depends_on:
- db
environment:
DATABASE_URL: postgres://app:app@db:5432/taskmanager
ports:
- "3000:3000"
command: sh -c "npm run migrate && npm start"One command: docker compose up. That is the bar.
9. How detailed should my data model be?
As detailed as the brief requires — no more. Over-engineering is a real failure mode. If the brief asks for tasks with titles and due dates, model exactly that. Add a created_at and updated_at (always sensible defaults), and an id. Stop there unless the brief calls for more.
CREATE TABLE tasks (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
title TEXT NOT NULL,
due_date TEXT, -- ISO 8601 string
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);10. Should I use an ORM or raw SQL?
Either is fine. What matters is consistency. Do not mix raw SQL in one place and an ORM in another. If you use Prisma, use it everywhere. If you use raw SQL, write a thin repository layer:
// src/repositories/task.repository.ts
import { db } from "../db";
export const TaskRepository = {
findAll(): Task[] {
return db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all() as Task[];
},
findById(id: string): Task | undefined {
return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as Task | undefined;
},
create(data: CreateTaskInput): Task {
const id = crypto.randomUUID();
db.prepare(
"INSERT INTO tasks (id, title, due_date) VALUES (?, ?, ?)"
).run(id, data.title, data.due_date ?? null);
return this.findById(id)!;
},
update(id: string, data: Partial<CreateTaskInput>): Task | undefined {
const task = this.findById(id);
if (!task) return undefined;
db.prepare(
"UPDATE tasks SET title = ?, due_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(data.title ?? task.title, data.due_date ?? task.due_date, id);
return this.findById(id);
},
delete(id: string): boolean {
const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
return result.changes > 0;
},
};PART 3: WRITING THE CODE
11. What does "clean code" actually mean in a take-home context?
Reviewers use "clean code" as a proxy for three things:
- 1Readability — can they understand it without asking you questions?
- 2Predictability — does it behave the way you would expect from reading it?
- 3Modifiability — can they extend it without touching unrelated parts?
Concrete checklist:
- Functions do one thing
- Names say what something IS or DOES, not how it works internally
- No commented-out code
- No magic numbers without named constants
- Error paths are as deliberate as happy paths
12. How do I handle input validation properly?
Validate at the boundary — the controller or route handler — before the data reaches your service layer. Use a schema validation library. Do not hand-roll if (!req.body.title) chains.
// Using zod
import { z } from "zod";
const CreateTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(255, "Title too long"),
due_date: z.string().datetime({ offset: true }).optional(),
});
// In your route handler:
app.post("/tasks", (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
});
}
const task = TaskService.create(result.data);
return res.status(201).json(task);
});13. What error format should my API return?
Pick one and use it everywhere. The RFC 7807 Problem Details format is a good default:
{
"type": "https://example.com/errors/not-found",
"title": "Resource not found",
"status": 404,
"detail": "Task with id '123' does not exist"
}Or a simpler format that is consistent:
{
"error": "Task not found",
"status": 404
}What kills candidates is inconsistency: a 400 that returns { "message": "..." } and a 404 that returns { "error": "..." }. That is not just stylistic — it means the caller has to handle two different shapes.
14. How do I write a proper error handling middleware?
// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from "express";
export class AppError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "AppError";
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.status).json({ error: err.message, status: err.status });
}
console.error(err);
return res.status(500).json({ error: "Internal server error", status: 500 });
}
// Usage in a service:
export function getTaskById(id: string): Task {
const task = TaskRepository.findById(id);
if (!task) throw new AppError(404, `Task with id '${id}' does not exist`);
return task;
}Register it last in your Express app: app.use(errorHandler).
15. How should I handle async errors in Express?
Wrap async route handlers to catch promise rejections:
// src/utils/async-handler.ts
import { Request, Response, NextFunction } from "express";
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export const asyncHandler = (fn: AsyncHandler) =>
(req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage:
app.get("/tasks/:id", asyncHandler(async (req, res) => {
const task = await TaskService.getById(req.params.id);
res.json(task);
}));Without this wrapper, an unhandled promise rejection will hang the response instead of returning a 500.
16. What logging should I add?
Enough to debug a failure in production without a debugger. At minimum:
- Each incoming request: method, path, status, duration
- Each unhandled error: full stack trace
- Never log passwords, tokens, or sensitive request bodies
// src/middleware/request-logger.ts
import { Request, Response, NextFunction } from "express";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
}17. How do I implement pagination for list endpoints?
Use cursor-based pagination if order matters (feeds, timelines). Use offset pagination if the reviewer just needs "list with pages" and it is simpler to implement correctly.
// Offset pagination
app.get("/tasks", (req, res) => {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
const offset = (page - 1) * limit;
const total = TaskRepository.count();
const tasks = TaskRepository.findAll({ limit, offset });
res.json({
data: tasks,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
});
});Document the default values. "Defaults to 20 results per page; max is 100" is the kind of explicit detail that impresses reviewers.
18. How do I implement basic authentication without making it the focus of the assignment?
If auth is in scope, use JWT with a clear, simple implementation:
// src/services/auth.service.ts
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
const JWT_SECRET = process.env.JWT_SECRET ?? "dev-secret-change-in-production";
const SALT_ROUNDS = 10;
export const AuthService = {
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
},
async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
},
signToken(userId: string): string {
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: "7d" });
},
verifyToken(token: string): { sub: string } {
return jwt.verify(token, JWT_SECRET) as { sub: string };
},
};
// src/middleware/auth.ts
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing or invalid Authorization header" });
}
try {
const payload = AuthService.verifyToken(header.slice(7));
res.locals.userId = payload.sub;
next();
} catch {
return res.status(401).json({ error: "Invalid or expired token" });
}
}If auth is NOT in scope, say so in your README and skip it entirely. Adding unrequested auth that is half-done is worse than no auth.
PART 4: TESTING
19. How many tests do I actually need?
The rule is not a number — it is coverage of behavior. Every public function in your service layer should have tests for:
- 1The happy path
- 2The main failure path (resource not found, invalid input)
- 3Any edge case the brief implies (empty list, duplicate data)
For a CRUD API with 5 endpoints, that usually means 15–25 test cases. Not 200, not 3.
20. Should I write unit tests or integration tests for a take-home?
Write integration tests against your actual routes. They give the reviewer the highest confidence and test the most code per test. Supplement with unit tests for complex business logic that is hard to exercise through the HTTP layer.
// __tests__/tasks.test.ts — integration test using supertest
import request from "supertest";
import { app } from "../src/app";
import { db } from "../src/db";
beforeEach(() => {
db.prepare("DELETE FROM tasks").run();
});
describe("POST /tasks", () => {
it("creates a task with a valid title", async () => {
const res = await request(app)
.post("/tasks")
.send({ title: "Write tests" })
.expect(201);
expect(res.body).toMatchObject({
id: expect.any(String),
title: "Write tests",
completed: false,
});
});
it("returns 400 when title is missing", async () => {
const res = await request(app)
.post("/tasks")
.send({})
.expect(400);
expect(res.body.error).toBe("Validation failed");
expect(res.body.details.title).toBeDefined();
});
it("returns 400 when title is empty string", async () => {
await request(app)
.post("/tasks")
.send({ title: "" })
.expect(400);
});
});
describe("GET /tasks/:id", () => {
it("returns the task when it exists", async () => {
const created = await request(app)
.post("/tasks")
.send({ title: "Existing task" })
.expect(201);
const res = await request(app)
.get(`/tasks/${created.body.id}`)
.expect(200);
expect(res.body.title).toBe("Existing task");
});
it("returns 404 when task does not exist", async () => {
const res = await request(app)
.get("/tasks/non-existent-id")
.expect(404);
expect(res.body.error).toMatch(/does not exist/i);
});
});21. How do I test a feature that depends on external services?
Mock them at the module boundary. If your service calls an email provider, create an interface and inject a stub in tests:
// src/services/email.service.ts
export interface EmailService {
sendWelcome(to: string): Promise<void>;
}
export class ResendEmailService implements EmailService {
async sendWelcome(to: string): Promise<void> {
// actual Resend API call
}
}
// __tests__/email.stub.ts
export class StubEmailService implements EmailService {
public sent: Array<{ to: string }> = [];
async sendWelcome(to: string): Promise<void> {
this.sent.push({ to });
}
}
// In test:
const emailService = new StubEmailService();
const userService = new UserService(emailService);
await userService.register({ email: "test@example.com", password: "hunter2" });
expect(emailService.sent).toHaveLength(1);
expect(emailService.sent[0].to).toBe("test@example.com");22. What testing tools should I use?
| Language | Test runner | HTTP testing |
|---|---|---|
| Node.js/TS | Vitest or Jest | supertest |
| Python | pytest | httpx or requests |
| Go | standard testing | net/http/httptest |
| Ruby | RSpec | rack-test |
Pick the obvious tool for the ecosystem. Reviewers should not need to learn a new tool to run your tests.
23. How do I make tests fast and reliable?
- Use an in-memory database (SQLite
:memory:) or reset state inbeforeEach - Never depend on execution order
- Never hit external network in unit/integration tests
- Use fixed seeds for random data in tests
// src/db.ts
import Database from "better-sqlite3";
const DB_PATH = process.env.NODE_ENV === "test"
? ":memory:"
: process.env.DATABASE_PATH ?? "app.db";
export const db = new Database(DB_PATH);24. What should my test output look like?
Tests should run with a single command (npm test, pytest, go test ./...) and produce clear output. Add a script to your package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Running with coverage is a bonus move — it shows you take quality seriously without being asked.
PART 5: CODE QUALITY AND DETAILS THAT SEPARATE CANDIDATES
25. What TypeScript patterns signal seniority?
Use unknown instead of any for external data, then narrow it:
// Bad — no safety
function parseConfig(raw: any) {
return raw.port; // could be anything
}
// Good — validates at the boundary
function parseConfig(raw: unknown): Config {
const result = ConfigSchema.safeParse(raw);
if (!result.success) throw new Error("Invalid config");
return result.data;
}Use discriminated unions for results instead of throwing everywhere:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function findTask(id: string): Result<Task, "not_found"> {
const task = TaskRepository.findById(id);
if (!task) return { ok: false, error: "not_found" };
return { ok: true, value: task };
}
// Caller handles both cases explicitly
const result = findTask(id);
if (!result.ok) return res.status(404).json({ error: "Task not found" });
return res.json(result.value);26. What environment configuration mistakes do candidates make?
The most common: hardcoded secrets and no .env.example.
Always provide an .env.example with every required variable and a safe placeholder:
# .env.example
PORT=3000
DATABASE_PATH=./app.db
JWT_SECRET=change-this-in-production
NODE_ENV=developmentUse a config module that validates on startup:
// src/config.ts
import { z } from "zod";
const ConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_PATH: z.string().default("./app.db"),
JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment variables:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;If the app starts with missing config, it fails loudly at boot — not silently at runtime.
27. Should I add linting and formatting?
Yes. One-time setup, permanent signal. Add ESLint and Prettier with a config file, run them in CI:
// package.json scripts
{
"scripts": {
"lint": "eslint src --ext .ts",
"format": "prettier --write src",
"format:check": "prettier --check src"
}
}A codebase with zero lint warnings communicates that you do not let quality debt accumulate.
28. How do I handle database migrations?
Never modify the schema in code without a migration file. Even for a take-home:
// src/db/migrations/001_create_tasks.ts
export const up = `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
due_date TEXT,
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`;// src/db/migrate.ts
import { db } from "../db";
import { up as migration001 } from "./migrations/001_create_tasks";
const migrations = [migration001];
export function migrate() {
db.prepare(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
run_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`).run();
migrations.forEach((sql, index) => {
const version = index + 1;
const already = db.prepare("SELECT 1 FROM schema_migrations WHERE version = ?").get(version);
if (!already) {
db.prepare(sql).run();
db.prepare("INSERT INTO schema_migrations (version) VALUES (?)").run(version);
console.log(`Migration ${version} applied.`);
}
});
}This is simple, has no dependencies, and demonstrates that you think about schema evolution.
29. What are the most common code review failures in take-homes?
Based on what senior engineers actually flag:
| Problem | Why it fails |
|---|---|
| No input validation | Security and reliability blind spot |
| No error handling | App silently returns 500 or hangs |
| Secrets in source code | Immediate disqualifier at security-conscious companies |
| No tests | Cannot refactor safely; signals low quality bar |
| Single giant file | Cannot navigate, cannot test in isolation |
| Inconsistent naming | get_user vs getUser vs fetchUser in same codebase |
| README that says "just works" without setup steps | Cannot be evaluated |
| Unused imports and dead code | Signals "copy-paste and move on" habit |
PART 6: DOCUMENTATION AND README
30. What does a great README look like?
A great README answers five questions in order:
- 1What does this do? (one sentence)
- 2How do I run it?
- 3How do I run the tests?
- 4What assumptions did I make?
- 5What would I do differently with more time?
# Task Manager API
A REST API for managing tasks with full CRUD operations, pagination, and JWT authentication.
## Requirements
- Node.js 18+
- No other dependencies (uses SQLite embedded)
## Setup
git clone <repo>
cd task-manager
cp .env.example .env
npm install
npm run migrate
npm start
Server runs at http://localhost:3000.
## Running Tests
npm test
## API Endpoints
| Method | Path | Description | Auth required |
|--------|--------------|-----------------------|---------------|
| POST | /auth/register | Register a user | No |
| POST | /auth/login | Get a JWT token | No |
| GET | /tasks | List tasks (paginated)| Yes |
| POST | /tasks | Create a task | Yes |
| GET | /tasks/:id | Get a task | Yes |
| PATCH | /tasks/:id | Update a task | Yes |
| DELETE | /tasks/:id | Delete a task | Yes |
Query parameters for GET /tasks: page (default: 1), limit (default: 20, max: 100), completed (true|false).
## Assumptions
- Tasks are scoped per authenticated user
- Due dates are optional and stored as ISO 8601 strings
- Deleting a user cascades to their tasks
## What I Would Add With More Time
- Refresh token rotation
- Soft deletes for tasks
- Full-text search on task titles
- Rate limiting per user31. Should I include an API client or Postman collection?
Yes, if it takes under 10 minutes to create. A Postman collection or Bruno collection that a reviewer can import and immediately test your endpoints removes all friction:
// bruno/Create Task.bru
meta {
name: Create Task
type: http
seq: 3
}
post {
url: {{baseUrl}}/tasks
body: json
auth: bearer
}
auth:bearer {
token: {{authToken}}
}
body:json {
{
"title": "Write integration tests",
"due_date": "2026-12-31T23:59:59Z"
}
}32. Should I add inline code comments?
Comment the WHY, not the WHAT. Code says what it does. Comments explain decisions that are not obvious:
// Good comment — explains a non-obvious decision
// We use PATCH instead of PUT because tasks have optional fields;
// PUT would require the client to send all fields to avoid overwriting with nulls.
// Bad comment — just restates the code
// Increment counter by 1
counter++;
// Good comment — explains a tricky edge case
// bcrypt.compare is intentionally constant-time to prevent timing attacks.
// Do not replace with a simple string comparison.
const valid = await bcrypt.compare(password, user.password_hash);PART 7: SUBMISSION
33. How should I deliver the final assignment?
In order of preference:
- 1GitHub/GitLab repository with a clean commit history
- 2Zip file of the project (exclude
node_modules, build artifacts)
Never send a zip with node_modules included. That is a 200MB file that tells the reviewer you have never done this before.
Your .gitignore should include at minimum:
node_modules/
dist/
.env
*.db
coverage/34. What does a clean commit history look like?
Three to eight commits that tell a story:
feat: initial project structure and dependencies
feat: add task CRUD with SQLite repository
feat: add JWT authentication
test: add integration tests for task endpoints
docs: add README with setup and API documentation
fix: validate due_date format before savingNot one commit that says "done" and not 47 commits that say "wip" and "fix typo."
If your history is messy, do a clean branch:
git checkout --orphan clean-submission
git add .
git commit -m "feat: task manager API with auth, tests, and documentation"35. What should I review before hitting send?
Run through this checklist:
- [ ]
npm install && npm run migrate && npm startworks from a fresh clone - [ ]
npm testpasses with zero failures - [ ] No
console.logdebug statements left in code - [ ]
.env.examplehas all required variables - [ ]
.envis in.gitignoreand not committed - [ ] No hardcoded secrets, API keys, or passwords
- [ ] README covers setup, tests, and assumptions
- [ ] All TypeScript errors resolved (
npx tsc --noEmit) - [ ] Linter passes (
npm run lint) - [ ] There is no dead code or unused imports
Read the original brief one more time after finishing. Candidates frequently implement 4 of 5 requirements and miss the fifth because they stopped reading after the first scan.
36. Should I over-deliver with extra features?
No. Implement what was asked, implement it well, and note extras you would add in your README. Adding unrequested features risks:
- Introducing bugs that break the core requirement
- Looking like you cannot scope your own work
- Distracting the reviewer from the features they are actually evaluating
One exception: if the brief is genuinely minimal and you finish the core implementation in half the time, adding one well-executed bonus feature that demonstrates depth — with a clear note that it is beyond scope — is fine.
PART 8: COMMON SCENARIOS AND ADVANCED QUESTIONS
37. The brief says "build a CLI tool." How does that change the approach?
The core principles are identical. What changes is your delivery mechanism. Structure business logic in a src/lib/ or src/services/ directory that knows nothing about CLI. The CLI layer in src/cli/ or bin/ is just parsing arguments and calling services.
// src/lib/tasks.ts — no CLI dependency
export function createTask(title: string, dueDate?: string): Task { ... }
export function listTasks(filter?: TaskFilter): Task[] { ... }
// src/cli/index.ts — only knows about CLI
import { program } from "commander";
import { createTask, listTasks } from "../lib/tasks";
program
.command("add <title>")
.option("-d, --due <date>", "due date in YYYY-MM-DD format")
.action((title, options) => {
const task = createTask(title, options.due);
console.log(`Created task: ${task.id}`);
});
program.parse();This structure makes the business logic testable without mocking stdin/stdout.
38. The brief asks for a React frontend. What is the minimum viable quality bar?
For a frontend assignment, the bar shifts:
- State management must be correct (no stale state, no race conditions)
- Loading and error states must be handled
- The form must not allow submitting while a request is in flight
- Components must be reasonably split (one giant App.tsx fails)
// Good: explicit loading and error states
function TaskList() {
const { data: tasks, isLoading, error } = useQuery({
queryKey: ["tasks"],
queryFn: () => api.getTasks(),
});
if (isLoading) return <p>Loading tasks...</p>;
if (error) return <p>Failed to load tasks. Please try again.</p>;
if (tasks?.length === 0) return <p>No tasks yet. Create one above.</p>;
return (
<ul>
{tasks?.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}39. The brief mentions "performance." What should I do?
Treat it as scoped unless they give you a specific load target. Show that you thought about it:
- Add indexes on columns used in WHERE clauses
- Do not run N+1 queries (fetch related data in a JOIN, not a loop)
- Add
Cache-Controlheaders on static responses
-- Index for common query patterns
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_completed ON tasks(completed, created_at DESC);Document what you did and why. "I added an index on (user_id, completed) to support the most common query pattern without a full table scan" is more impressive than adding 20 indexes without explanation.
40. How do I handle the "implement search" requirement?
For a take-home, SQL full-text search is sufficient and shows you know the right tool for the job size:
// SQLite FTS5 (full-text search)
// Migration:
`CREATE VIRTUAL TABLE tasks_fts USING fts5(title, content='tasks', content_rowid='rowid')`
// Trigger to keep FTS in sync:
`
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts(rowid, title) VALUES (new.rowid, new.title);
END;
`
// Search query:
app.get("/tasks/search", (req, res) => {
const query = req.query.q as string;
if (!query?.trim()) return res.json({ data: [] });
const results = db.prepare(`
SELECT tasks.* FROM tasks
JOIN tasks_fts ON tasks.rowid = tasks_fts.rowid
WHERE tasks_fts MATCH ?
ORDER BY rank
LIMIT 20
`).all(query.trim()) as Task[];
res.json({ data: results, query });
});Note in your README: "Search is implemented with SQLite FTS5. At scale I would use a dedicated search service like Typesense or Elasticsearch."
41. What if the take-home uses a stack I do not know well?
Be honest in your README — then deliver the best code you can. "I have not used Go in production; this is my first substantial Go project. I would appreciate feedback on Go idioms I may have missed." That honesty paired with solid fundamentals is better than pretending expertise you do not have.
The fundamentals transfer: clean separation of concerns, proper error handling, tests, and good documentation are language-agnostic.
42. Should I use Docker?
Use it if the project has infrastructure dependencies (a database, a cache, an external service). Do not use it for a self-contained project to look impressive — it adds setup complexity for no gain.
If you use Docker, the following must all work:
docker compose up # starts everything
docker compose exec app npm test # runs tests inside container43. What do reviewers actually do with my submission?
Most reviewers:
- 1Read the README first
- 2Attempt to run the project
- 3Run the tests
- 4Read the code structure (file tree)
- 5Spot-check 2–3 areas in depth: usually validation, error handling, and data access
- 6Look for tests on the feature they care most about
They spend 20–45 minutes total. Your README, your test coverage, and your error handling are the three places that give the clearest signal in that time window.
44. Should I use AI tools to help me?
You can use AI tools the same way you would use documentation or Stack Overflow — to look up syntax, understand a library, or generate a boilerplate snippet you would then review and modify. What you cannot do is submit code you do not understand.
Interviewers can tell. They ask follow-up questions. If you cannot explain why your middleware is registered in a specific order, or why you chose a certain data structure, the interview falls apart. Use AI as a speed multiplier on things you already know, not as a substitute for knowledge.
45. What is the single most important thing I can do to stand out?
Make it trivially easy to run. The candidate who submits an assignment that works on the first try, with clear output, zero configuration confusion, and tests that pass — that candidate starts the review from a position of trust. Everything else is commentary.
The candidate who requires the reviewer to debug their setup before even seeing the code starts from a deficit they rarely recover from.
PART 9: LANGUAGE-SPECIFIC TIPS
46. What are the Python-specific things I should nail?
- Use type hints everywhere (Python 3.10+)
- Use
pydanticfor data validation - Structure with
src/layout and a properpyproject.toml - Use
pytestwithpytest-asyncioif using async
# src/models/task.py
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_date: Optional[datetime] = None
class Task(TaskCreate):
id: str
completed: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True47. What are the Go-specific things I should nail?
- Return errors explicitly; never panic in a handler
- Use
errors.Isanderrors.Asfor error checking - Structure as
cmd/,internal/,pkg/ - Use table-driven tests
// internal/task/service.go
var ErrTaskNotFound = errors.New("task not found")
func (s *Service) GetByID(ctx context.Context, id string) (*Task, error) {
task, err := s.repo.FindByID(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("task %q: %w", id, ErrTaskNotFound)
}
if err != nil {
return nil, fmt.Errorf("get task: %w", err)
}
return task, nil
}
// In handler:
task, err := svc.GetByID(r.Context(), id)
if errors.Is(err, task.ErrTaskNotFound) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}48. What are the Java/Kotlin Spring Boot things I should nail?
- Use constructor injection, not
@Autowiredon fields - Write controller tests with
@WebMvcTestand service tests with unit tests - Return
ResponseEntitywith explicit status codes - Use Bean Validation annotations on your DTOs
data class CreateTaskRequest(
@field:NotBlank(message = "Title is required")
@field:Size(max = 255)
val title: String,
@field:FutureOrPresent(message = "Due date must be in the future")
val dueDate: LocalDateTime?
)
@RestController
@RequestMapping("/tasks")
class TaskController(private val taskService: TaskService) {
@PostMapping
fun create(@Valid @RequestBody req: CreateTaskRequest): ResponseEntity<TaskResponse> {
val task = taskService.create(req)
return ResponseEntity.status(HttpStatus.CREATED).body(task)
}
}49. What should I do in the 30 minutes before submitting?
- 1Delete all debug
console.log/print/fmt.Printlnstatements - 2Run
npm test(or equivalent) from a clean terminal and verify it passes - 3Clone your own repo into a new directory and follow your README from scratch
- 4Read the brief one more time and verify every requirement has a corresponding test
- 5Check that your
.envis not committed:git log --all --full-history -- .env
50. What follow-up questions should I prepare for after submission?
Reviewers use the take-home as the foundation for the technical interview. Common follow-up questions:
- "Walk me through your data model. Why did you structure it this way?"
- "How would this scale to a million tasks?"
- "Your tests use SQLite. What would change when you move to Postgres?"
- "What would you add first if you had another two days?"
- "I see you used [library X]. Why that over [library Y]?"
- "There is a bug in your [endpoint]. Can you find it?"
The right preparation is to understand every decision you made — not just what you built, but why you built it that way and what you would do differently at larger scale.
Final Word
A take-home assignment is not a test of how fast you can code. It is a test of how you make decisions under reasonable time pressure, how you communicate those decisions to other engineers, and whether your code will not break the first time someone else touches it.
The engineers who consistently pass these assignments share one habit: they read the brief carefully, plan before coding, write the unhappy paths with the same care as the happy paths, and treat the README as part of the deliverable.
That is the complete bar. Clear it deliberately.