Step 22 of 24
Pairing & take-home interviews
Pass machine-coding, take-home and pair-programming rounds by building a small, tested, well-structured program that is easy to extend under time pressure.
Many product companies don't stop at algorithm puzzles. They also ask you to build something real: a take-home assignment over a few days, a 90-minute "machine coding" round, or a pairing session where you extend a codebase with an engineer beside you. Gojek became known in Southeast Asia for exactly this — a take-home such as a command-line parking lot, followed by a pairing round where you extend your own solution live. These rounds test the things the rest of this roadmap teaches: clean code, SOLID, TDD and pairing. This lesson covers what reviewers look for, how to approach the build, a worked example, and how to handle the live extension.
What reviewers look for
The program working is the minimum. A reviewer reading your submission asks: would I be happy to inherit this code? Typical criteria, roughly in order of weight:
| Criterion | What good looks like |
|---|---|
| Correctness | Handles the spec and the obvious edge cases; runs with one command |
| Tests | Meaningful unit tests for the logic, plus an end-to-end test of the main flow; written alongside the code |
| Design | Clear separation of concerns: domain logic independent of I/O; small types with one responsibility |
| Extensibility | A plausible new requirement is a small, local change — without speculative frameworks |
| Readability | Good names, small functions, idiomatic for the language, consistent formatting |
| Process | A sensible Git history of small commits; a README explaining how to build, test, run and what you assumed |
What fails candidates is rarely "it didn't work". It's a 600-line main.go, no tests or tests added at the end, logic mixed with fmt.Println, a single commit called "done", or the opposite — an enterprise framework with factories and abstract base types for a 200-line problem.
Before you write code
- Read the problem twice and list every requirement and every example input/output. Sample outputs are a test suite handed to you — use them verbatim.
- List your assumptions and edge cases: invalid commands, capacity zero, duplicates, empty input. In a take-home, put them in the README. In a live round, ask.
- Pick boring tools: your strongest language, its standard test runner, no frameworks unless asked. In Go: a module,
go test,go vet,gofmt. - Set up a walking skeleton first: a project that builds, runs one trivial command, and has one passing test, plus a script (
bin/setup,bin/run, or aMakefile) if the instructions ask for one. Commit it. Now every later step is incremental. - Time-box. For a 90-minute round, aim to have a working core in 60 minutes, leaving time for edge cases, cleanup and the README. For a take-home, a few focused hours beats a sprawling weekend — reviewers notice gold-plating too.
Worked example: a parking lot
The classic prompt: build a command-line program that manages a parking lot with numbered slots. Commands arrive one per line, from a file or interactively:
create_parking_lot 6
park B-1234-XYZ White
park B-9999-ABC Black
leave 1
status
registration_numbers_for_cars_with_colour White
slot_number_for_registration_number B-9999-ABCCars park in the nearest free slot to the entrance (the lowest number). The program prints a line for each command, such as Allocated slot number: 1 or Sorry, parking lot is full.
Separate the domain from I/O
The most important design decision is to keep parking logic free of parsing and printing. The domain knows nothing about command strings or stdout:
package parking
var (
ErrFull = errors.New("parking lot is full")
ErrInvalidSlot = errors.New("no such slot")
ErrNotFound = errors.New("not found")
)
type Car struct {
Registration string
Colour string
}
type Lot struct {
slots []*Car // slots[0] is slot 1; nil means free
}
func NewLot(capacity int) (*Lot, error) {
if capacity < 1 {
return nil, fmt.Errorf("capacity must be positive, got %d", capacity)
}
return &Lot{slots: make([]*Car, capacity)}, nil
}
// Park puts the car in the lowest-numbered free slot and returns that slot.
func (l *Lot) Park(car Car) (int, error) {
for i, occupant := range l.slots {
if occupant == nil {
l.slots[i] = &car
return i + 1, nil
}
}
return 0, ErrFull
}
// Leave frees the given slot. Freeing an empty slot is not an error.
func (l *Lot) Leave(slot int) error {
if slot < 1 || slot > len(l.slots) {
return ErrInvalidSlot
}
l.slots[slot-1] = nil
return nil
}
// SlotFor returns the slot holding the car with the given registration.
func (l *Lot) SlotFor(registration string) (int, error) {
for i, c := range l.slots {
if c != nil && c.Registration == registration {
return i + 1, nil
}
}
return 0, ErrNotFound
}
// Find returns the occupied slots whose car matches, in slot order.
func (l *Lot) Find(match func(Car) bool) []Slot {
var out []Slot
for i, c := range l.slots {
if c != nil && match(*c) {
out = append(out, Slot{Number: i + 1, Car: *c})
}
}
return out
}
type Slot struct {
Number int
Car Car
}Note Find taking a predicate — a small strategy. "Cars by colour", "status" (match everything) and future queries all reuse it without new methods.
Test the domain first
Written test-first, the domain tests read like the specification:
func TestPark_UsesNearestFreeSlot(t *testing.T) {
lot, _ := parking.NewLot(3)
lot.Park(parking.Car{Registration: "A", Colour: "White"})
lot.Park(parking.Car{Registration: "B", Colour: "Black"})
if err := lot.Leave(1); err != nil {
t.Fatal(err)
}
slot, err := lot.Park(parking.Car{Registration: "C", Colour: "Red"})
if err != nil {
t.Fatal(err)
}
if slot != 1 {
t.Errorf("Park() = slot %d; want 1 (nearest free)", slot)
}
}
func TestPark_ReturnsErrFullWhenNoSlots(t *testing.T) {
lot, _ := parking.NewLot(1)
lot.Park(parking.Car{Registration: "A"})
if _, err := lot.Park(parking.Car{Registration: "B"}); !errors.Is(err, parking.ErrFull) {
t.Errorf("Park() error = %v; want ErrFull", err)
}
}Commands as a table, not a switch
The command layer parses a line, calls the domain, and formats the output. A map from command name to handler keeps each command small and makes adding one a local change (open–closed):
type handler func(args []string) (string, error)
type Shell struct {
lot *parking.Lot
commands map[string]handler
}
func NewShell() *Shell {
s := &Shell{}
s.commands = map[string]handler{
"create_parking_lot": s.create,
"park": s.park,
"leave": s.leave,
"status": s.status,
"registration_numbers_for_cars_with_colour": s.registrationsByColour,
"slot_number_for_registration_number": s.slotForRegistration,
}
return s
}
func (s *Shell) Execute(line string) string {
fields := strings.Fields(line)
if len(fields) == 0 {
return ""
}
cmd, ok := s.commands[fields[0]]
if !ok {
return fmt.Sprintf("Unknown command: %s", fields[0])
}
if s.lot == nil && fields[0] != "create_parking_lot" {
return "Parking lot has not been created"
}
out, err := cmd(fields[1:])
if err != nil {
return messageFor(err)
}
return out
}
func (s *Shell) park(args []string) (string, error) {
if len(args) != 2 {
return "", errUsage("park <registration> <colour>")
}
slot, err := s.lot.Park(parking.Car{Registration: args[0], Colour: args[1]})
if err != nil {
return "", err
}
return fmt.Sprintf("Allocated slot number: %d", slot), nil
}messageFor maps domain errors to the exact output strings the spec requires (ErrFull → Sorry, parking lot is full). main just reads lines from a file or stdin and prints Execute's result. An end-to-end test feeds the sample input through Shell and compares against the sample output byte for byte.
Commit as you go
A history like this tells the reviewer you work incrementally:
Add walking skeleton with go test and run script
Park cars in the nearest free slot
Free a slot when a car leaves
Report status of occupied slots
Look up registrations by colour and slot by registration
Add command shell and file input
End-to-end test against the sample session
Document assumptions and usage in READMEDesigning for the extension round
After a take-home, the follow-up is often a live session: "now add X". The extension tests whether your design was genuinely easy to change — and how you work with a pair. Typical asks for this example: multiple floors; vehicle types with different slot sizes; hourly pricing on exit; persistence across restarts; nearest-slot lookup faster than a scan for very large lots.
You can't predict the ask, so don't build any of these up front — that's speculative generality. Instead, keep the properties that make any change cheap: domain separated from I/O, small types with one job, good tests, no global state. Then, during the session:
- Clarify the new requirement with examples, out loud.
- Write a failing test first. It shows the interviewer your process and pins down the behaviour.
- Make the change easy, then make the easy change — a quick refactor if needed, then the feature.
- Narrate trade-offs: "A linear scan is fine for a few hundred slots; for 100,000 I'd keep free slots in a min-heap so parking is O(log n)."
- Keep tests green and commit at each step, if the session uses Git.
Common pitfalls
- No tests, or tests bolted on at the end with weak assertions. For many reviewers, this alone is a rejection.
- Logic in
mainor in the command parser; printing from deep inside domain code. - Over-engineering: interfaces for everything, a dependency injection framework, plugin systems. It reads as poor judgment, not skill.
- Ignoring the exact output format — automated graders often diff it.
- Global mutable state (a package-level
var lot *Lot) that makes tests depend on each other. - No README, or one that doesn't say how to run the tests.
- Copying a solution from the internet. Reviewers recognise popular solutions, and the extension round exposes it immediately.
Practice
Extend the parking lot: the operator wants to charge on exit. The first hour costs Rp 5,000, and each additional started hour costs Rp 3,000. leave now takes the registration number and the exit time, and prints the fee. Describe the design change (which types change, which are new), and write the fee calculation with its tests.
Show solution
Design: fees are a new responsibility, so they get their own type rather than growing Lot. Lot needs to remember when each car arrived, so Park takes an entry time (passed in, not read from the clock, so tests are deterministic) and stores it with the car. A Tariff computes the fee from a duration; the leave command looks up the car's slot and entry time, frees the slot, and asks the tariff for the fee.
type Tariff struct {
FirstHour int64
AdditionalHour int64
}
// Fee returns the charge for a stay of duration d. Any started hour is charged.
func (t Tariff) Fee(d time.Duration) int64 {
if d <= 0 {
return 0
}
hours := int64((d + time.Hour - 1) / time.Hour) // round up to started hours
return t.FirstHour + (hours-1)*t.AdditionalHour
}func TestTariffFee(t *testing.T) {
tariff := Tariff{FirstHour: 5_000, AdditionalHour: 3_000}
tests := []struct {
stay time.Duration
want int64
}{
{0, 0},
{time.Minute, 5_000},
{time.Hour, 5_000},
{time.Hour + time.Second, 8_000},
{3*time.Hour + 30*time.Minute, 14_000},
}
for _, tt := range tests {
t.Run(tt.stay.String(), func(t *testing.T) {
if got := tariff.Fee(tt.stay); got != tt.want {
t.Errorf("Fee(%v) = %d; want %d", tt.stay, got, tt.want)
}
})
}
}Boundary cases — exactly one hour, one second over — are where fee bugs hide, so they're in the table. Ask the interviewer whether a zero-length stay should be free or charged the first hour; it's a product decision, and asking is the right move. Because the tariff is its own type, a later "weekend pricing" request is a new Tariff value or a small interface, not an edit to Lot.
Key takeaways
- Take-home, machine-coding and pairing rounds judge whether others would happily maintain your code: tests, design, readability and process, not just correctness.
- Start with requirements, assumptions and a walking skeleton; time-box and commit in small steps.
- Keep domain logic independent of parsing and printing, and test it first.
- Design for change with good structure, not speculative features; use simple tools like a command table and predicates.
- In the extension round: clarify, write a failing test, refactor if needed, implement, and narrate trade-offs.
- Avoid the classic failures: no tests, logic in
main, over-engineering, wrong output format, global state, no README.
Check yourself
Quiz
0/4 correct · 4 left
1.Which submission is most likely to be rejected despite producing correct output?
2.Why should the parking Lot type not print messages like 'Allocated slot number: 1' itself?
3.In the live extension round you're asked to add hourly pricing. What should you do first?
4.A candidate builds an abstract VehicleFactory, a pricing plugin system and multi-floor support that the spec never asked for. How is this usually perceived?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- YAGNI: you aren’t gonna need itDon’t build features or generality before a real requirement needs them — but keep the tests and structure that make adding them cheap.
- Open–closed principleAdd new behaviour by adding code rather than editing tested code, at the points where variation is real.
- Red, green, refactorThe TDD loop: write a failing test, make it pass with the simplest code, then clean up with the tests green.
Finished reading?
Mark it complete to fill in your roadmap.