The interviewer hands you a paragraph written as if by a product owner: "we need a limit check before payments", "we need a notification filter", "we need an ATM". There is barely any algorithm here, and the task looks easy. Yet an hour later the candidate has a twenty-line method returning boolean, double for money and not a single test.
This round is called low level design: it checks how a person writes ordinary production code. What matters are the decisions made along the way: which questions to ask, which types describe the data, how to draw the boundary with neighbouring services and how to prove that everything works. Below is the order to follow and the mistakes that cost points.
Six steps in order: questions, types and contracts come before the logic; tests and the trade-off discussion come after it.
Order of work
The most common mistake is to start writing the method right away. Twenty minutes later it turns out the limit includes the current payment and a notification for a user without settings must still be sent, and the code gets rewritten.
So the first five minutes go to clarifications. Good questions are about boundaries: what "exactly at the limit" means, whether 24 hours is a sliding window or a calendar day, what to do when a user has no settings, what matters more for an ATM, fewer notes or saving small ones. Write the answers at the top of the file: the interviewer sees that you understood the requirements, and you get a basis for the tests.
Then models and types, then interfaces for external services, then the logic, then tests. At the end, a couple of minutes on trade-offs: what was simplified and what would change under higher load.
Types: money, time and the answer
Money is never stored in double: a binary fraction cannot represent 0.1 exactly, and cents start to disappear. Use BigDecimal or whole cents in a long. BigDecimal has its own trap: equals compares both value and scale, so 5000 and 5000.00 are different numbers to it. Compare with compareTo:
live example
import java.math.BigDecimal;
public class Money {
public static void main(String[] args) {
BigDecimal limit = new BigDecimal("5000");
BigDecimal payment = new BigDecimal("5000.00");
System.out.println("equals: " + limit.equals(payment));
System.out.println("compareTo: " + (limit.compareTo(payment) == 0));
System.out.println("double: " + (0.1 + 0.2));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Time comes from an injected Clock, not from Instant.now(). Otherwise "within the last 24 hours" cannot be tested: every run sees its own "now". With a fixed clock the window start is known in advance:
live example
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
public class WindowStart {
static Instant windowStart(Clock clock) {
return clock.instant().minus(Duration.ofHours(24));
}
public static void main(String[] args) {
Clock fixed = Clock.fixed(Instant.parse("2026-10-06T12:00:00Z"), ZoneOffset.UTC);
System.out.println("window starts at " + windowStart(fixed));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The answer is a dedicated type, not a boolean. The product owner asked to say which limit is exceeded, and false does not tell that. An exception does not fit either: a limit refusal is a normal outcome, not a failure.
Don't:
boolean check(Payment payment)
Do:
enum LimitViolation { SINGLE_PAYMENT_LIMIT, DAILY_LIMIT }
record CheckResult(boolean allowed, LimitViolation violation) {}
A fixed set of options — notification channels, banknote denominations, refusal reasons — is an enum. A typo like "SMSS" cannot slip through, and the compiler points to every place that must handle a new option.
Contracts for external services
The task almost always says: "settings and history live in other components, design the contracts". This checks whether you see the cost of a call. A getSettings(userId) called for every notification turns into ten thousand requests to a neighbour in a campaign of ten thousand recipients.
Contracts are shaped by the task and work in batches: one request for all recipients, a map in the response. History returns what the decision needs, not everything: a daily limit needs the sum for the period, there is no point in shipping the list of payments over the network. The difference in the number of calls is obvious:
live example
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
public class BatchCalls {
static int calls = 0;
static Map<String, String> settingsOf(Collection<String> users) {
calls++;
Map<String, String> result = new HashMap<>();
for (String user : users) {
result.put(user, "SMS");
}
return result;
}
public static void main(String[] args) {
List<String> recipients = List.of("u1", "u2", "u1", "u3");
for (String user : recipients) {
settingsOf(List.of(user));
}
System.out.println("one by one: " + calls + " calls");
calls = 0;
settingsOf(new HashSet<>(recipients));
System.out.println("batched: " + calls + " call");
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
If the task says a call is slow — counting banknotes in an ATM takes ten seconds — call it as rarely as possible: read once, keep the result in memory and update it yourself after each withdrawal. Cheap checks go before expensive ones: an amount not divisible by 50 is rejected without touching the hardware.
Tests with mocks
Tests are half of the grade. External services are replaced with Mockito mocks: they are interfaces you designed yourself, so mocking them costs nothing. One test per rule, plus separate tests for boundaries: exactly at the limit, one cent above, a window of exactly 24 hours, an empty list.
@Test
void exceedingDailyLimitIsRejectedWithReason() {
when(limits.limitsOf("u1")).thenReturn(new UserLimits(new BigDecimal("10000.00"), new BigDecimal("5000.00")));
when(history.spentSince(any(), any())).thenReturn(new BigDecimal("7000.00"));
CheckResult result = checker.check(new Payment("u1", new BigDecimal("3000.01"), NOW));
assertEquals(CheckResult.reject(LimitViolation.DAILY_LIMIT), result);
}
Check not only the result but also how the code talks to its neighbours. verify(settings, times(1)) proves the settings were requested in one batch, verifyNoInteractions(history) proves the expensive call did not happen when the answer was known earlier.
Common mistakes
Points are usually lost in the same places. Code is started without a single clarification and then rewritten. Money is kept in double, and time is taken from Instant.now() right inside the logic. A refusal comes back as false or null, and the caller does not know why. A contract is called in a loop. And tests are left for the end, where no time remains. It is easier to write a test right after each rule.
Summary
- Order: clarifications → types → contracts → logic → tests → trade-offs.
- Money is
BigDecimalwithcompareToor cents in along, neverdouble. - Time comes from an injected
Clock; tests useClock.fixed(...). - The answer is a type with a reason;
enumfor channels, denominations and reasons. - Contracts are batched and shaped by the task; a slow call is made once, then cached.
- Cheap checks go before expensive calls.
- A test per rule and per boundary,
verifyon the number of calls to neighbours.
Further reading
- Mocks and external services — when to replace a dependency and how not to end up testing the mocks.
- SOLID — dependency inversion, which contracts to neighbouring services rely on.
- DRY, KISS, YAGNI — how not to build too much in an hour-long interview.
- Test pyramid — where the unit tests of this round belong.