Как сделать хорошее API
Обстоятельно и подробно, на конкретных примерах рассказываю как спроектировать и реализовать API, за которое потом не будет стыдно.

Что это и зачем
An application programming interface (API) is a way for two or more computer programs to communicate with each other. It is a type of software interface, offering a service to other pieces of software.[1]
Программный интерфейс приложения (такой перевод автору больше нравится), это такой метод взаимодействия между программами — способ из одной программы вызвать другую.
Полагаю, даже из столь общего описания уже понятно насколько важно уметь «правильно готовить» это самое API.
Автору известны случаи преждевременной смерти проекта из‑за плохой реализации API, отказы от использования и потери клиентов — по этой же причине.
Разумеется речь про проекты, в которых API является существенной частью оказываемой услуги. Ну и конечно множество случаев срыва сроков разработки и вылезания далеко за границы бюджета — для всех остальных.
Разные реализации API обусловлены техническими ограничениями или историческим наследием, а самым универсальным и «чистым» вариантом на данный момент является REST.
Вот на нем и сфокусируемся, хотя описанное достаточно универсально и применимо для большинства других вариантов реализации API.
Сценарии использования
Поскольку статья про достаточно абстрактные вещи, слабо воспринимаемые «на слух» без соответствующего опыта работы архитектором, опишу реальные сценарии, при которых возникает необходимость проектирования и реализации API.
Служба доставки
Есть служба доставки грузов и посылок разными способами, с помощью API необходимо давать возможность регистрировать заявки и отслеживать посылку из внешних систем.
Система бронирования
Есть сеть отелей со своей службой бронирования, с помощью API необходимо реализовать бронирование номеров из внешних агрегаторов вроде Booking.com
Вебсервис для очистки фона картинок
Есть «смешной сайт в интернете», предоставляющий сервис по автоматическому удалению фона с загружаемых пользователями изображений с помощью нейросетей.
С помощью API необходимо обеспечить работу с этим сервисом из сторонних приложений, например в виде плагина для Adobe Photoshop.
Как видите все три сценария абсолютно реальные и взяты из выполненных проектов, поэтому описанное в статье имеет под собой твердую практическую основу.

Минимальный неделимый смысл
Как бы банально это ни звучало, прежде чем начинать делать нужно подумать, по отношению к проектированию API — нужно подумать о минимально возможном сценарии использования.
Допустим, вы создали сервис для отправки СМС.
Без метода API, собственно позволяющего отправить СМС — смысл существования такого сервиса теряется.
Поэтому минимальный неделимый смысл для подобного сервиса — вызов метода отправки СМС. Без работоспособности этого метода — все API целиком считается неработающим.
Зачем это все надо?
Чтобы иметь четкое направление развития API и понимать, какие именно функции в дальнейшем нельзя удалять.
Опыт подсказывает, что самый рациональный вариант развития API это «дерево, растущее вширь», но не подходы вроде «API это просто набор вызываемых методов».
Резюмируя:
метод отправки СМС в вашем API будет абсолютно всегда, в любых версиях и при любых условиях.
Конечно со временем будут добавлены новые методы, но минимальный неделимый смысл останется прежним — в виде того самого метода отправки СМС.
Понимаю что это слабо воспринимается на слух и у вас уже чешется клавиатура рассказать автору насколько он неправ, но я видел слишком много чужих реализаций API в стиле «коричневая гора и пара палок», поэтому просто обязан был вставить абзац, призывающий к здравому смыслу — может хоть кто‑то прочитает и задумается.

Структуры данных
Думаю очевидно что при вызове методов API обычно нужно передавать и получать параметры:
строки, числа, булевые флажки, даты и так далее.
Параметров в большинстве случаев много, поэтому их набор оформляют в специальные объекты DTO — Data Transfer Object и уже такие DTO передают и получают.
Считаю что однозначно стоит использовать DTO вместо примитивных типов при проектировании любого API и в любых случаях, несмотря на усложение и увеличение кодовой базы.
И да, для такого утверждения есть серьезные причины.
Допустим, есть метод API (Spring MVC):
@RequestMapping(value = "/api/updates/receive", method = RequestMethod.POST)
public ResponseEntity receiveUpdate(@RequestParam Long taskId,
@RequestParam String updateInfo,
@RequestParam LocalDateTime updateDt) {
//логика обработки
..
}
Что произойдет если появится необходимость добавить новый параметр?
Если вы забудете отключить признак обязательности (required=false) для нового параметра — при вызове из старых клиентов API, не знающих о новом параметре, метод будет порождать ошибку.
Наверное во всех API есть понятие «сигнатуры метода»: комбинация из названия, модификаторов и аргументов, включая типы данных:
без знания сигнатуры, правильный вызов метода API становится невозможным.
В примере выше добавление параметра меняет эту самую сигнатуру, отчего и происходит сбой. Наилучшим вариантом, который позволит сохранить обратную совместимость является использование DTO:
/**
пример метода с использованием DTO
*/
@RequestMapping(value = "/api/updates/receive", method = RequestMethod.POST)
public ResponseEntity receiveUpdate(@RequestBody UpdateRequestDTO dto) {
//логика обработки
...
}
/**
пример DTO
*/
class UpdateRequestDTO {
private Long taskId;
private String updateInfo;
private LocalDateTime updateDt;
// новый параметр
private boolean shinyNewFeatureSwitch;
}
Устаревший клиент, использующий данный метод API, новое поле просто не заполнит, поэтому объект DTO будет создан со значением по‑умолчанию для этого поля.
И все будут счастливы, на какое-то время.

Вложенность в DTO
Существует мощный инструмент, который точно стоит использовать при проектировании DTO — вложенность.
Допустим у вас есть метод API, который отдает профиль пользователя — много много разных полей, описывающий все его данные: ФИО, телефон, почту, роли, связанные документы и так далее.
Вы все это видели неоднократно, в любой современной системе.
Можно конечно реализовать «в лоб», просто перечислив все поля профиля пользователя подряд:
public class UserProfile {
private String firstName, lastName, middleName;
private String email;
private String workPhone;
// и так далее еще 100500 полей
...
}
Но что будет когда понадобится реализовать поддержку нескольких адресов почты или телефонов? А как насчет нескольких вариантов имени — отдельно на национальном языке и отдельно на английском?
Тут тоже есть простой вариант — добавлять новые поля сплошной «простыней», усложняя тем самым бизнес‑логику по их использованию.
Но есть вариант лучше, называется «проектирование»:
public class UserProfile {
// DTO с основным блоком данных о пользователе
private BasicInfo basicInfo;
// список DTO с дополнительными блоками данных
// для поддержки нескольких вариантов имен
private List additionalInfo;
// основные контакты пользователя (есть у 100% пользователей)
private ContactInfo mainContacts;
// дополнительные контакты (есть у 1-2% особо #бнутых)
private List additionalContacts;
}
// отдельные DTO для блоков данных
class BasicInfo {
private String firstName, lastName, middleName;
}
class ContactInfo {
private String email;
private String workPhone;
}
Что важно отметить:
разделение на блоки позволяет структурировать данные и упростить обработку, не ломая при этом обратную совместимость — старая версия клиента API не увидит новые блоки, поэтому случаи ошибочной обработки будут сведены к минимуму.
разделение на главный и второстепенные блоки упрощает логику валидации — достаточно собрать обязательные поля в главном блоке и валидировать только его.
Валидируется лишь главный блок, а дополнительные используются только если заполнены — идеально для например логики отправки уведомлений, где как раз актуальны дополнительные контакты.

Синхронные и асинхронные вызовы
Полагаю читатели в курсе, что на свете существуют «синхронные» и «асинхронные» методы API.
В первом случае вся логика метода отрабатывает сразу в момент вызова и результат отдается немедленно, во втором происходит запуск фоновой обработки, а вместо результата отдается «ссылка на получение». Сам же результат отдается отдельным методом, которому указывается эта ссылка.
Пожалуйста никогда не делайте синхронное API.
Синхронное API — одна из ключевых причин низкой производительности вебсервисов.
Нет ни исключений ни «особых случаев» ни разумных причин для создания синхронных методов публичного API, такого в хорошем API быть не должно:
@Controller
public class MdmController {
@ResponseBody
@RequestMapping(value = "/package/{packageId}/collect/{collectorType}",
method = RequestMethod.POST)
public Object collectStatistics(@PathVariable long packageId,
@PathVariable String collectorType) {
Map result = new HashMap<>();
MdmPackage mdmPackage = mdmPackageDao.getById(packageId);
if (mdmPackage == null) {
result.put("success", false);
result.put("errorMessage", "Not found package by ID: " + packageId);
return result;
}
ICollector collector = collectorFactory.getCollector(collectorType);
if (collector == null) {
result.put("success", false);
result.put("errorMessage", "Not found collector by type: " +
collectorType);
return result;
}
try {
collector.collect(mdmPackage);
result.put("success", true);
} catch (MDMCollectException e) {
result.put("success", false);
result.put("errorMessage", e.getMessage());
}
return result;
}
Как только вы связали внутреннее состояние сервиса с внешней средой через синхронное API — вы проиграли в битве за производительность.
Пример кода выше был взят из реального проекта и после рефакторинга стал выглядеть так:
@ResponseBody
@RequestMapping(value = "/package/{packageId}/collect/{collectorType}", method = RequestMethod.POST)
public Object collectStatistics(@PathVariable long packageId,
@PathVariable String collectorType) {
return cacheService.getCachedStatsFor(packageId);
}
Заполнение кэша со статистикой происходит в отдельном потоке:
@Service
public class CacheService {
// хранилище со статистикой
private Map> statsCache = new HashMap<>();
/**
фоновое обновление статистики в отдельном потоке
*/
@Scheduled(initialDelay=5000)
public void refreshStats() {
// получаем все MDM-пакеты из базы
List foundPackages = ..;
// собираем и обновляем статистику
for (MdmPackage p: foundPackages) {
Map stats = collectStats(p);
statsCache.put(p.getPackageId(),stats);
}
}
/**
Получить статистику по id пакета
*/
public Map getCachedStatsFor(long packageId) {
return statsCache.containsKey(packageId)?
statsCache.get(packageId): Collections.emptyMap();
}
}
Опустим вопрос размера данных, помещаемых в память — очевидно что для задач статистики их не может быть слишком много.
После рефакторинга, скорость ответа метода стала предсказуемой, вне зависимости от количества клиентов.
Но самое главное — больше не будет линейной нагрузки на используемые внешние ресурсы, в первую очередь СУБД, поскольку реальное чтение данных происходит в фоне и в одном потоке, без связи с клиенсткими потоками.
Если возникнут проблемы с подключением к базе данных — клиент API этого не увидит, если вместо одного вызова метода, кто-то из клиентов сделает пару тысяч — ничего не сломается и продолжит работать.
Если нужно принять данные от клиента, используется аналогичный подход, поэтому вот так делать не стоит:
@RestController
public class QuestionnaireController {
@Transactional
@PostMapping
public Questionnaire create(Users operator,
CurrentArchiveDeals deals,
Integer durationRate,
Integer queueRate,
Integer politeRate,
Integer comfortRate, Integer informRate) {
Questionnaire questionnaire = new Questionnaire();
// заполнение полей
..
Query q = emf.createEntityManager().createNativeQuery("select nextval('questionnaire_seq')");
// сохранение в базу данных
...
return questionnaire;
}
}
В это примере, также взятом из реального проекта (но разумеется там был не вызов nextval), каждый вызов метода API create открывает транзакцию и блокирует выделенное соединение из пула подключений к базе на время записи.
Несмотря на то что каждая блокировка происходит лишь на миллисекунды, достаточно пары тысяч таких вызовов чтобы пошли ошибки записи и откаты транзакций.
Если необходимо записать данные в базу или на диск из входящего вызова — делайте это асинхронно:
@Service
public class QuestionnaireService {
public String createNew(Questionnaire questionnaire) {
UUID requestId = UUID.randomUUID();
questionnaire.setRequestId(requestId);
createNewAsync(questionnaire);
return requestId.toString();
}
public Questionnaire getByRequestId(String requestId) {
// получение из базы выборкой по id запроса или отдача null
..
}
@Async
void createNewAsync(Questionnaire questionnaire) {
try {
createNewTrans(questionnaire);
} catch (Exception e) {
// ignore
}
}
@Transactional
void createNewTrans(Questionnaire questionnaire) {
Query q = emf.createEntityManager()
.createNativeQuery("select nextval('questionnaire_seq')");
// сохранение в базу данных
}
}
Вот так выглядит контроллер с API после рефакторинга:
@RestController
public class QuestionnaireController {
@Autowired
private QuestionnaireService qs;
public String create(Users operator,
CurrentArchiveDeals deals,
Integer durationRate,
Integer queueRate,
Integer politeRate,
Integer comfortRate, Integer informRate) {
Questionnaire questionnaire = new Questionnaire();
// заполнение полей
..
return qs.createNew(questionnaire);
}
public Questionnaire getByRequestId(String requestId) {
return qs.getByRequestId(requestId);
}
}
Как видите, тут произошла разбивка на три метода: сам метод записи, метод проверки результата и метод API, который лишь создает запрос на запись и отправляет в очередь обработки.
Наружу отдается уникальный ID запроса, по которому можно получить результат выполнения.
В результате, цепочка:
вызов метода API → результат
превращается в:
вызов метода API → запрос на обработку → обработка → проверка готовности результата → результат
Да, такая реализация сложнее и объемнее чем синхронная версия, но к сожалению асинхронность является необходимостью в современных реалиях, поскольку дает автономность вашего API от больных фантазий пользователей:
Вызов удаленного REST-метода в цикле на пару тысяч итераций — ныне норма и асинхронное API это лучший способ от подобного не страдать.

Особенности HTTP
Большинство современных протоколов интеграции и взаимодействия между системами так или иначе основаны на веб-технологиях и используют протокол HTTP.
SOAP, REST, XML-RPC — все эти протоколы работают поверх HTTP.
Хотя в некоторых случаях заявляется поддержка и альтернативных транспортных протоколов, самое массовое использование все равно происходит поверх HTTP/HTTPS.
Поэтому например любой SOAP-запрос представляет собой HTTP POST с XML-документом в теле запроса и несколькими дополнительными HTTP-заголовками.
Также необходимо пояснить, что существует важная разница в обработке GET и POST (технически еще PUT/DELETE/HEAD) запросов:
ответ GET-запроса по-умолчанию кешируется.
Как на клиентской стороне (браузером и клиентскими библиотеками), так и на серверной — всевозможными прокси и «API Gateway» решениями.
При этом никто и никогда не кеширует POST, поскольку он изначально предназначался для отправки данных.
Отсюда рекомендация:
всегда использовать исключительно POST-запросы при реализации методов API, в том числе для методов получения данных.
Да, это идет в разрез с общепринятыми рекомендациями по проектированию RESTful сервисов, но зато работает во всех случаях.
Не нужно думать про сбитую системную дату на клиенте, влияющую на срок жизни кэшированных данных или об обрезанных или измененных HTTP-заголовках (что часто делают прокси и шлюзы), которые влияют на включение кеширования там где не надо.
Наверное видели как клиентские Javascript библиотеки добавляют параметр со случайным числом к запросу?
Что-то вроде:
GET /api/uptime?t=2232449494
Это один из способов гарантированно обойти кеширование на стороне клиента, если нет возможности для POST — используйте хотя‑бы такой трюк.

Коды ответа
Если пользуетесь интернетом хоть иногда — обязательно видели страницы с ошибками. Чаще всего с кодом 404 «страница не найдена», 500 «внутренняя ошибка», реже 403 «нет доступа».
Существуют рекомендации по использованию HTTP кодов ответа при проектировании REST API, вроде возврата кода 404 если данные не найдены, 400 (Bad Request) при ошибке валидации входящих параметров и 500 при внутренней ошибке.
Опыт автора показывает, что все несколько сложнее и так просто HTTP-коды использовать не стоит:
Коды 404 и 500 в ответе очень часто подпадают под автоматическую обработку на клиентской стороне, в виде реализации такой логики в клиентских библиотеках.
Чаще всего произойдет автоматический повторный запрос через несколько секунд. В логах сервера соответственно будет видно несколько вызовов и записано несколько трассировок с ошибкой.
Коды 404, 400,405, 403,500 обрабатываются системами мониторинга для сайтов и вебсервисов.
Если какой-то из методов, проверяемый такой системой отдаст код ошибки — это появится в системе мониторинга, даже если ошибка вполне себе «бизнесовая» — в логике работы и не является признаком собственно сбоя.
Код 403 «В доступе отказано» обрабатывается сканерами уязвимостей, которые сейчас работают автоматически.
Может случиться что сканер найдет API отдающее код 403 и автоматически пойдет подбирать учетные данные, бомбардируя ваш несчастный сервер запросами.
Внезапно и API Gateway и обычный прокси Nginx, которые чаще всего будут стоять перед вашим сервисом с API — тоже программы, в которых тоже бывают ошибки.
И уже эти системные ошибки абсолютно всегда обрабатываются через стандартные HTTP-коды вроде 500, 400 и 405.
Самый частый пример — загрузка большого файла через POST-запрос и прокси-сервер вроде Nginx, в котором существует своя настройка лимитов на размер запроса, количества загружаемых файлов и размеров.
Если Nginx не был заранее настроен — увидите 500 либо 405 ошибку, которую выбросит сам Nginx, а не ваш сервис.
Резюмируя:
лучший вариант — свой набор кодов ошибок, специфичных для вашей системы, которые отдаются отдельным полем в DTO.
Что касается HTTP‑кодов — во всех случаях нужно отдавать стандартный 200 OK, за исключением каких‑то серьезных ошибок, которые должны быть зарегистрированы мониторингом.
Вся валидация данных, включая проверку на существование записи, все проверки доступа — везде выдается стандартный 200 ОК, но не 400, 405, 403.
Ниже пример подобного DTO, который часто используется на наших проектах:
@ApiModel(description = "Общая модель ответа. Используется для выдачи ответа от всех управляющих методов.")
public static class Result implements Serializable {
private static final long serialVersionUID = 1L;
// код ответа
private final int code;
// текстовое сообщение
private final String message;
// дополнительные параметры
private final Map flags = new TreeMap<>();
private final Date createdAt = new Date();
@JsonCreator
public Result(@JsonProperty("code") int code,
@JsonProperty("message") String message) {
this.code = code; this.message = message;
}
@JsonCreator
public Result(@JsonProperty("code") int code,
@JsonProperty("message") String message,
@JsonProperty("flags") Map flags) {
this.code = code; this.message = message;
this.flags.clear(); this.flags.putAll(flags);
}
@JsonProperty(access = Access.READ_ONLY)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@ApiModelProperty(value = "дата и время генерации ответа")
public Date getCreatedAt() {
return createdAt;
}
@JsonProperty(access = Access.READ_ONLY)
@JsonSerialize(using = IntToHexStringSerializer.class, as = Integer.class)
@ApiModelProperty(value = "числовой код ответа")
public int getCode() {
return code;
}
@JsonProperty(access = Access.READ_ONLY)
@ApiModelProperty(value = "текстовое сообщение ответа")
public String getMessage() {
return message;
}
@JsonProperty(access = Access.READ_ONLY)
@ApiModelProperty(value = "дополнительные параметры ответа")
public Map getFlags() {
return flags;
}
public static Result of(int code) {
// получить сообщение об ошибке без префикса
final String message = SystemError.messageForPrefix(code, false);
return new Result(code, message);
}
public static Result of(int code, Map flags) {
// получить сообщение об ошибке без префикса
final String message = SystemError.messageForPrefix(code, false);
return new Result(code, message, flags);
}
}
Коды и описания ошибок выглядят примерно так:
sgate.system.error.0x6013=Cannot delete user, user not found: {}
sgate.system.error.0x6014=No routers present
sgate.system.error.0x6015=User not found: {}
sgate.system.error.0x6016=User has no roles: {}
sgate.system.error.0x7001=ok
sgate.system.error.0x7002=bad secret
sgate.system.error.0x7003=requestId is blank.
sgate.system.error.0x7004=request not found
Дальше эти коды ошибок описываются в документации и используются на клиенте при обработке логики.
При таком подходе, практически всегда можно быстро отделить «мух от котлет» — логическую ошибку в API, предусмотренную cценарием использования от проблем окружения.
Возможно описанное выглядит ненужной работой, но представьте что таких API в системе — несколько тысяч, реализованных в разных вебсервисах, еще и вызываемых через сложные цепочки один из другого.

Сессии и токены
Пожалуйста забудьте термин «публичное API» — на дворе давно не 1970е и идейных хиппи за компьютерами больше нет. И не будет.
На данный момент отсутствие какой-либо идентификации пользователя это 100% риск DDOS-атак и последующей перегрузки вашей инфраструктуры.
Быстро вы это не локализуете (из-за отсутствия идентификации) и не остановите. И делают все это роботы в автоматическом режиме, а не живые люди.
В зависимости от задачи, надо реализовать либо сессионную модель либо токен доступа к API. В случае REST и Java, чаще всего это будет использование JWT‑токена, передаваемого отдельным HTTP‑заголовком либо механизма сессий в сервлет‑контейнере.
Но самое главное:
обязательно должен быть механизм ограничения доступа для сессии или клиентского токена.
Ваш сервис, предоставляющий API должен уметь блокировать доступ для конкретного клиента. Дело в том что цепочки вызовов API это самые настоящие потоки данных, а как известно — поток без контроля приводит к наводнению.
Нельзя в 21 веке делать публичное API без контроля доступа, никак и совсем.
По хорошему даже внутренее API требует примитивной авторизации, например по токену, передаваемому HTTP‑заголовком.
Даже если вы разрабатываете полностью публичный бесплатный вебсервис — введите бесплатную регистрацию и раздавайте ключи, но не связывайте себе руки отсутствием контроля доступа.
Тем более, что под маской анонима ныне скрываются не маргинальные личности, а всевозможные роботы, которые рано или поздно найдут применение вашему публичному API и выделяемым ресурсам.

Отключаемость и заглушки
Хорошее API обязательно должно быть отключаемым, хотя-бы ради задачи обслуживания.
Разумеется самый простой вариант это просто физически выключить сервер, выдернуть провод, остановить виртуальную машину или убить запущенный процесс — что‑то такое.
К сожалению в современных реалиях такой шаг вызовет кучу ненужных последствий:
сработают системы мониторинга, нарушится связность системы, поскольку клиенты API будут вынуждены прекратить работу из-за ошибок подключения.
Вполне может быть и каскадный сбой, когда из‑за отключения одного сервиса вылетает десяток других по цепочке вызова.
Чтобы свести к минимуму возможные последствия, мы реализуем переключатель, при активации которого вместо реальных данных отдаются заглушки — пустые структуры, а сам признак «API отключено» отдается с помощью числового кода (см. выше).
Таким образом на клиентской стороне при временном отключении API ничего не поломается — клиент не получит данных, но сам вызов отработает.
По хорошему такая же заглушка также должна включаться при слишком большом количестве запросов от конкретного клиента и при сбое внешних сервисов — например СУБД.

Собственный клиент
Во всех случаях когда это возможно — создавайте готовые клиентские библиотеки для вашего API.
Свой клиент, созданный для вашего API позволит скрыть протокол взаимодействия от кривых рук пользователя и избавиться от огромного количества проблем, вроде устаревания методов или неправильного использования вашего сервиса.
Никакие инструкции, примеры и документация не помогут так как это сделает готовая клиентская библиотека.
Хотя это еще не главный козырь:
Как думаете, какое количество пользователей станут добровольно исправлять и дорабатывать клиентский код при обновлениях вашего API?
Подскажу: ноль.
Старым кодом будут пользоваться до тех пор пока он хоть как‑то работает, поскольку доработка и исправления это риск и расходы.
Поэтому если хотите чтобы вашим API пользовалась максимально широкая аудитория с минимальной головной болью — создавайте выкладывайте клиентские библиотеки.
Хотя-бы под основные платформы, окружения и языки разработки.
Эпилог
Создание хорошего API — своего рода искусство, требующее серьезного практического опыта, ведь фактически будучи разработчиком, вы создаете инструмент для других разработчиков или AI-агентов, что очевидно непросто.
Надеюсь данная статья поможет хотя-бы некоторым читателям в этом нелегком деле.
Также это приглашение к диалогу и обмену практическим опытом, если есть что добавить и чем поделиться по данной теме — welcome в комментарии.
P.S.
Это почищенная и облагороженная версия статьи, оригинал которой доступен в нашем блоге.
Изложенные подходы взяты из реальной практики автора и постоянно им применяются, также они врядли устареют в обозримом будущем.