← назад к разделу

На собеседовании дают абзац текста от лица заказчика: «нужна проверка лимитов перед платежом», «нужен фильтр рассылки», «нужен банкомат». Алгоритма здесь почти нет, и кажется, что задача простая. Но час проходит, а от кандидата остаётся метод на двадцать строк с 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 — как не построить лишнего за час собеседования.
  • Пирамида тестов — где место юнит-тестам из этой секции.