На собеседовании дают абзац текста от лица заказчика: «нужна проверка лимитов перед платежом», «нужен фильтр рассылки», «нужен банкомат». Алгоритма здесь почти нет, и кажется, что задача простая. Но час проходит, а от кандидата остаётся метод на двадцать строк с true или false на выходе, деньги в дробных числах и ни одного теста.
Эту секцию называют low level design: проверяют, как человек пишет обычный рабочий код. Важны не трюки, а решения, которые принимаются по дороге: какие спросить уточнения, какими типами описать данные, как спроектировать границу с соседними сервисами и чем доказать, что всё работает. Ниже порядок, которого стоит держаться, и ошибки, на которых теряют баллы.
Шесть шагов по порядку: до логики идут вопросы, типы и контракты, после неё — тесты и разговор о компромиссах.
Порядок работыспросят на собеседовании
Самая частая ошибка — сразу писать метод. Через двадцать минут выясняется, что лимит включает текущий платёж, а уведомление без настроек получателя надо отправлять, и код переписывается.
Поэтому первые пять минут уходят на уточнения. Хорошие вопросы касаются границ: что значит «ровно на лимите», считаются ли 24 часа скользящим окном или календарными сутками, что делать, если у пользователя нет настроек, что важнее при выдаче денег — меньше купюр или сберечь мелкие. Ответы записывают прямо в начало файла: интервьюер видит, что требования поняты, и у вас есть опора для тестов.
Дальше модели и типы, потом интерфейсы внешних сервисов, потом логика, потом тесты. В конце пара минут на компромиссы: что сделано упрощённо и что поменялось бы при росте нагрузки.
Типы: деньги, время и ответспросят на собеседовании
Деньги не хранят в числе с плавающей точкой: двоичная дробь не умеет точно записать 0.1, и копейки начинают теряться. Время не берут из системных часов прямо в логике, а ответ не сводят к «да или нет». Как это выглядит в каждом языке:
Для денег берут BigDecimal или целые копейки в long. У BigDecimal своя ловушка: equals сравнивает и значение, и масштаб, поэтому 5000 и 5000.00 для него разные числа. Сравнивают через compareTo:
живой пример
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));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Время берут не из Instant.now(), а из внедрённого Clock. Иначе условие «за последние 24 часа» невозможно проверить тестом: каждый запуск видит своё «сейчас». С фиксированными часами граница окна известна заранее:
живой пример
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
public class Window {
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("окно начинается с " + windowStart(fixed));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ответ — отдельный тип, а не boolean. Заказчик просил сказать, какой лимит превышен, а false этого не говорит. Исключение тоже не подходит: отказ по лимиту — обычный исход, а не сбой.
Так не надо:
boolean check(Payment payment)
Так надо:
enum LimitViolation { SINGLE_PAYMENT_LIMIT, DAILY_LIMIT }
record CheckResult(boolean allowed, LimitViolation violation) {}
Набор вариантов — каналы уведомлений, номиналы купюр, причины отказа — описывают enum. Так невозможно передать опечатку вроде "SMSS", и компилятор подскажет, где ещё нужно обработать новый вариант.
Десятичного типа в стандартной библиотеке Go нет. Деньги держат в целых копейках int64, часто под своим именем вроде type Kopecks int64; для курсов и процентов берут пакет shopspring/decimal. Ловушка с дробями в Go хитрее обычного: константы компилятор считает точно, и 0.1+0.2 печатает честные 0.3. Ошибка проявляется, только когда числа лежат в переменных:
живой пример
package main
import "fmt"
func main() {
a, b := 0.1, 0.2
fmt.Println("float64:", a+b)
fmt.Println("константы:", 0.1+0.2)
var limit, payment int64 = 500000, 500000
fmt.Println("копейки равны:", limit == payment)
fmt.Printf("к выводу: %d.%02d ₽\n", payment/100, payment%100)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Время берут не из time.Now(), а из внедрённой функции часов. Иначе условие «за последние 24 часа» невозможно проверить тестом: каждый запуск видит своё «сейчас». С фиксированными часами граница окна известна заранее:
живой пример
package main
import (
"fmt"
"time"
)
type Clock func() time.Time
func windowStart(now Clock) time.Time {
return now().Add(-24 * time.Hour)
}
func main() {
fixed := func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) }
fmt.Println("окно начинается с", windowStart(fixed).Format(time.RFC3339))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ответ — отдельный тип, а не bool. Заказчик просил сказать, какой лимит превышен, а false этого не говорит. Ошибка error тоже не подходит: отказ по лимиту — обычный исход, а не сбой.
Так не надо:
func Check(p Payment) bool
Так надо:
type LimitViolation int
const (
NoViolation LimitViolation = iota
SinglePaymentLimit
DailyLimit
)
type CheckResult struct {
Allowed bool
Violation LimitViolation
}
Перечислений в Go нет, их заменяет свой тип с константами через iota. Нулевое значение NoViolation выбрано нарочно: пустой CheckResult{} не должен означать «превышен разовый лимит». Строку с опечаткой вроде "SMSS" в такой тип не передать, а линтер exhaustive подскажет switch, в котором забыт новый вариант.
В JavaScript одно число на всё: Number — это та же двоичная дробь. Деньги держат в целых копейках: целые в Number точны до 2^53 − 1, это 90 триллионов рублей. Если не хватает, берут BigInt, а для курсов и процентов — библиотеку десятичных чисел вроде decimal.js:
живой пример
console.log("number:", 0.1 + 0.2);
const limit = 500000;
const payment = 500000;
console.log("копейки равны:", limit === payment);
console.log("к выводу:", (payment / 100).toFixed(2), "₽");
console.log("целые точны до:", Number.MAX_SAFE_INTEGER);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Время берут не из Date.now(), а из внедрённой функции часов. Иначе условие «за последние 24 часа» невозможно проверить тестом: каждый запуск видит своё «сейчас». С фиксированными часами граница окна известна заранее:
живой пример
const DAY_MS = 24 * 60 * 60 * 1000;
function windowStart(now) {
return new Date(now() - DAY_MS);
}
const fixed = () => Date.parse("2026-10-06T12:00:00Z");
console.log("окно начинается с", windowStart(fixed).toISOString());
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ответ — объект с причиной, а не true или false. Заказчик просил сказать, какой лимит превышен, а false этого не говорит. Исключение тоже не подходит: отказ по лимиту — обычный исход, а не сбой.
Так не надо:
function check(payment) {
return payment.amount <= 500000;
}
Так надо:
живой пример
const LimitViolation = Object.freeze({
SINGLE_PAYMENT_LIMIT: "SINGLE_PAYMENT_LIMIT",
DAILY_LIMIT: "DAILY_LIMIT",
});
const allowed = () => ({ allowed: true, violation: null });
const rejected = (violation) => ({ allowed: false, violation });
console.log(allowed());
console.log(rejected(LimitViolation.DAILY_LIMIT));
console.log(LimitViolation.DAYLY_LIMIT);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Перечислений в JavaScript нет, варианты собирают в замороженный объект констант. Это лучше голых строк, но не спасает от опечатки: DAYLY_LIMIT молча даёт undefined. Поэтому решения на JavaScript на собеседовании часто пишут на TypeScript: там варианты описывают объединением строковых литералов, и опечатку ловит компилятор.
Для денег берут Decimal или целые копейки в int. Сравнение у Decimal честное по значению: 5000 и 5000.00 равны. Ловушка в другом месте: Decimal(0.1) из числа с плавающей точкой переносит его ошибку целиком. Создают из строки:
живой пример
from decimal import Decimal
limit = Decimal("5000")
payment = Decimal("5000.00")
print("равны:", limit == payment)
print("из float:", Decimal(0.1))
print("из строки:", Decimal("0.1"))
print("float:", 0.1 + 0.2)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Время берут не из datetime.now(), а из внедрённой функции часов. Иначе условие «за последние 24 часа» невозможно проверить тестом: каждый запуск видит своё «сейчас». С фиксированными часами граница окна известна заранее:
живой пример
from datetime import datetime, timedelta, timezone
def window_start(now):
return now() - timedelta(hours=24)
fixed = lambda: datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
print("окно начинается с", window_start(fixed).isoformat())
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ответ — отдельный тип, а не bool. Заказчик просил сказать, какой лимит превышен, а False этого не говорит. Исключение тоже не подходит: отказ по лимиту — обычный исход, а не сбой.
Так не надо:
def check(payment) -> bool:
return payment.amount <= 5000
Так надо:
живой пример
from dataclasses import dataclass
from enum import Enum
class LimitViolation(Enum):
SINGLE_PAYMENT_LIMIT = "single"
DAILY_LIMIT = "daily"
@dataclass(frozen=True)
class CheckResult:
allowed: bool
violation: LimitViolation | None = None
print(CheckResult(False, LimitViolation.DAILY_LIMIT))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Набор вариантов — каналы уведомлений, номиналы купюр, причины отказа — описывают Enum. Опечатка LimitViolation.DAYLY_LIMIT сразу падает с AttributeError, а строка "SMSS" прошла бы молча. frozen=True делает результат неизменяемым и даёт сравнение по полям, на котором держатся тесты.
Контракты внешних сервисовспросят на собеседовании
В условии почти всегда сказано: «настройки и история живут в других компонентах, спроектируйте контракты». Это проверка того, видите ли вы стоимость вызова. Метод получения настроек по одному пользователю, вызванный на каждое уведомление, в рассылке на десять тысяч получателей превращается в десять тысяч запросов к соседу.
Контракт делают под задачу и пачкой: один запрос на всех получателей, ответ — словарь. История отдаёт то, что нужно для решения, а не всё подряд: для суточного лимита хватит суммы за период, список платежей по сети гнать незачем. Разница в числе вызовов видна сразу:
живой пример
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
public class Batch {
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("по одному: " + calls + " запроса");
calls = 0;
settingsOf(new HashSet<>(recipients));
System.out.println("пачкой: " + calls + " запрос");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
var calls = 0
func settingsOf(users []string) map[string]string {
calls++
result := make(map[string]string, len(users))
for _, user := range users {
result[user] = "SMS"
}
return result
}
func unique(users []string) []string {
seen := map[string]bool{}
var result []string
for _, user := range users {
if !seen[user] {
seen[user] = true
result = append(result, user)
}
}
return result
}
func main() {
recipients := []string{"u1", "u2", "u1", "u3"}
for _, user := range recipients {
settingsOf([]string{user})
}
fmt.Println("по одному:", calls, "запроса")
calls = 0
settingsOf(unique(recipients))
fmt.Println("пачкой:", calls, "запрос")
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
let calls = 0;
function settingsOf(users) {
calls++;
return new Map([...users].map((user) => [user, "SMS"]));
}
const recipients = ["u1", "u2", "u1", "u3"];
for (const user of recipients) {
settingsOf([user]);
}
console.log("по одному:", calls, "запроса");
calls = 0;
settingsOf(new Set(recipients));
console.log("пачкой:", calls, "запрос");
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
calls = 0
def settings_of(users):
global calls
calls += 1
return {user: "SMS" for user in users}
recipients = ["u1", "u2", "u1", "u3"]
for user in recipients:
settings_of([user])
print("по одному:", calls, "запроса")
calls = 0
settings_of(set(recipients))
print("пачкой:", calls, "запрос")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если в условии сказано, что вызов медленный — пересчёт купюр в банкомате идёт десять секунд, — его вызывают как можно реже: читают один раз, держат результат в памяти и обновляют сами после каждой выдачи. Дешёвые проверки ставят до дорогих: сумму, не кратную 50, отклоняют, не трогая железо.
Тесты с мокамиспросят на собеседовании
Тесты — половина оценки. Внешние сервисы в них подменяют: это интерфейсы, которые вы сами спроектировали, поэтому подмена ничего не стоит. На каждое правило — свой тест, и отдельно тесты на границы: ровно на лимите, копейка сверху, окно ровно 24 часа назад, пустой список.
Подменяют моками Mockito:
@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);
}
Проверяют не только результат, но и то, как код обращается к соседям. verify(settings, times(1)) доказывает, что настройки запрошены одной пачкой, verifyNoInteractions(history) — что дорогой вызов не случился, когда ответ был известен раньше.
В Go подмену обычно пишут руками: маленькая структура, которая реализует ваш интерфейс и считает вызовы. Библиотеки моков вроде gomock есть, но для интерфейса в один-два метода ручная заглушка короче и понятнее:
type fakeHistory struct {
spent int64
calls int
}
func (f *fakeHistory) SpentSince(string, time.Time) int64 {
f.calls++
return f.spent
}
func TestExceedingDailyLimitIsRejectedWithReason(t *testing.T) {
history := &fakeHistory{spent: 700000}
checker := NewLimitChecker(fakeLimits{single: 500000, daily: 1000000}, history, fixedNow)
got := checker.Check(Payment{UserID: "u1", Amount: 300001})
want := CheckResult{Allowed: false, Violation: DailyLimit}
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}
func TestSingleLimitIsCheckedBeforeHistory(t *testing.T) {
history := &fakeHistory{}
checker := NewLimitChecker(fakeLimits{single: 500000, daily: 1000000}, history, fixedNow)
checker.Check(Payment{UserID: "u1", Amount: 600000})
if history.calls != 0 {
t.Fatalf("история запрошена %d раз, а ответ был известен раньше", history.calls)
}
}
Проверяют не только результат, но и то, как код обращается к соседям. Счётчик в заглушке доказывает, что настройки запрошены одной пачкой и что дорогой вызов не случился, когда ответ был известен раньше.
Подменяют встроенным mock.fn() из node:test, без внешних библиотек:
const { test, mock } = require("node:test");
const assert = require("node:assert/strict");
const limits = { limitsOf: () => ({ single: 500000, daily: 1000000 }) };
test("превышение суточного лимита отклоняется с причиной", () => {
const history = { spentSince: mock.fn(() => 700000) };
const result = new LimitChecker(limits, history).check("u1", 300001);
assert.deepEqual(result, { allowed: false, violation: LimitViolation.DAILY_LIMIT });
});
test("разовый лимит проверяется раньше истории", () => {
const history = { spentSince: mock.fn(() => 0) };
new LimitChecker(limits, history).check("u1", 600000);
assert.equal(history.spentSince.mock.callCount(), 0);
});
Проверяют не только результат, но и то, как код обращается к соседям. mock.callCount() доказывает, что настройки запрошены одной пачкой и что дорогой вызов не случился, когда ответ был известен раньше.
Подменяют Mock из стандартного unittest.mock:
class LimitCheckerTest(unittest.TestCase):
def setUp(self):
self.limits = Mock()
self.limits.limits_of.return_value = (Decimal("5000.00"), Decimal("10000.00"))
self.history = Mock()
self.checker = LimitChecker(self.limits, self.history)
def test_exceeding_daily_limit_is_rejected_with_reason(self):
self.history.spent_since.return_value = Decimal("7000.00")
result = self.checker.check("u1", Decimal("3000.01"))
self.assertEqual(CheckResult(False, LimitViolation.DAILY_LIMIT), result)
def test_single_limit_is_checked_before_history(self):
result = self.checker.check("u1", Decimal("6000.00"))
self.assertEqual(LimitViolation.SINGLE_PAYMENT_LIMIT, result.violation)
self.history.spent_since.assert_not_called()
Проверяют не только результат, но и то, как код обращается к соседям. assert_called_once() доказывает, что настройки запрошены одной пачкой, assert_not_called() — что дорогой вызов не случился, когда ответ был известен раньше.
Частые ошибки
Чаще всего баллы теряют на одном и том же. Код начинают писать без единого уточнения и потом переписывают. Деньги хранят в числе с плавающей точкой, а текущее время берут из системных часов прямо в логике. Отказ возвращают как false или пустое значение, и вызывающий не знает причины. Контракт зовут в цикле. И тесты откладывают на конец, а на конец времени не остаётся. Проще с самого начала писать тест сразу после каждого правила.
Коротко
- Порядок: уточнения → типы → контракты → логика → тесты → компромиссы.
- Деньги —
BigDecimalсcompareToили копейки вlong, никогдаdouble. - Время — через внедрённый
Clock, в тестахClock.fixed(...). - Ответ — тип с причиной;
enumдля каналов, номиналов и причин.
- Деньги — копейки в
int64, для процентовshopspring/decimal, никогдаfloat64. - Время — через внедрённую функцию
func() time.Time, в тестах фиксированная. - Ответ — структура с причиной; варианты — свой тип с константами через
iota.
- Деньги — целые копейки (точны до 2^53 − 1) или
BigInt, не дробныйNumber. - Время — через внедрённую функцию
now(), в тестах фиксированная. - Ответ — объект с причиной; варианты —
Object.freezeили литералы TypeScript.
- Деньги —
Decimalиз строки или копейки вint, никогдаfloat. - Время — через внедрённую функцию часов, в тестах фиксированная.
- Ответ —
dataclass(frozen=True)с причиной;Enumдля каналов, номиналов и причин.
- Контракты пакетные и под задачу; медленный вызов — один раз, дальше кэш.
- Дешёвые проверки раньше дорогих вызовов.
- Тест на каждое правило и на каждую границу, плюс проверка числа вызовов соседей.
Что почитать дальше
- Моки и внешние сервисы — когда подменять зависимость и как не превратить тест в проверку моков.
- SOLID — инверсия зависимостей, на которой держатся контракты к соседним сервисам.
- DRY, KISS, YAGNI — как не построить лишнего за час собеседования.
- Пирамида тестов — где место юнит-тестам из этой секции.
- Моки и внешние сервисы — когда подменять зависимость и как не превратить тест в проверку моков.
- SOLID — инверсия зависимостей, на которой держатся контракты к соседним сервисам.
- DRY, KISS, YAGNI — как не построить лишнего за час собеседования.
- Пирамида тестов — где место юнит-тестам из этой секции.
- SOLID — инверсия зависимостей, на которой держатся контракты к соседним сервисам.
- DRY, KISS, YAGNI — как не построить лишнего за час собеседования.
- Пирамида тестов — где место юнит-тестам из этой секции.
- Моки и внешние сервисы — когда подменять зависимость и как не превратить тест в проверку моков.
- SOLID — инверсия зависимостей, на которой держатся контракты к соседним сервисам.
- DRY, KISS, YAGNI — как не построить лишнего за час собеседования.
- Пирамида тестов — где место юнит-тестам из этой секции.