Введение

Повидав десятки разных приложений на NestJS, да и на других фреймворках, я выяснил, что одна из главных сильных и слабых сторон JavaScript - свобода выбора путей решения задач.

Именно свобода и максимальная гибкость, которые данный язык предлагает разработчикам, больше всего влияет на качество проектов на нём. Язык позволяет решать задачи и строить приложения практически как угодно. И у большинства приложений бекэнда я замечаю одно и то же: спустя год, расширять и изменять их становится крайне неприятной задачей, за которую никто не захочет браться.

Да, я принимаю, что на других языках ситуации могут быть схожими, но я буду говорить только про "своё болото", и об его улучшении.

Туториал является сугубо субъективным, отталкиваясь от опыта увиденных приложений на NestJS, и решать, что со всем этим делать только вам.

О чем будем говорить

Сначала выведем несколько проблем и дополним каждую из них, а затем узнаем, какие решения предлагаются автором, а затем сделаем выводы.

Краткая справка и мысли про NestJS

Когда-то, во времена, когда люди передвигались на саблезубых тиграх, а концепции асинхронности не было, и программы еще не умели ничего обещать, разработчики писали серверные приложения на нативном модуле http, фреймворке koa, или же, скорее всего, на express - довольно удобном и прорывным на то время фреймворком.

Некоторые же последовали примеру представленных выше фреймворков и начали создавать свои, те самые, фреймворки - про мемичность этого явления знают все. Время шло, деревья росли, и в 2017 вышел, как на данный момент можно судить, прорывной фреймворк на Node.js, который был встречен очень неоднозначно, но продолжал уверенно развиваться и набирать популярность.

Взяв устоявшиеся практики из Angular, добавив свой стандартный механизм внедрения зависимостей, фреймворк начал унифицировать бекэнд разработку, предоставляя мощность и гибкость, а также, упрощение расширение кодовой базы проектов.

По собственному мнению и мнениям из круга товарищей-разработчиков, NestJS является очень удобным, понятным и простым в разработке фреймворком. Создание и интеграция модулей, использование декораторов, родная поддержка TypeScript, возможность выбора фреймворка под NestJS - всё это повлияло на мой выбор, и в основном я стал работать именно на нём, оставив express для простых приложений, где модули были бы лишним нагромождением.

В чём же проблема?

Разобрав немало коммерческих и внутренних проектов на NestJS, я могу выделить следующие проблемы, а затем мы перейдем к их решениям.

Проблема - Отсутствие абстрактного и(ли) графического (доменного) представления

Некоторые разработчики просто создают папки с файлами, не понимая, что они должны делать на уровне решения проблем бизнеса. Нет разграничения контекстов, зон и слоёв ответственности, что рано или чуть позже превращает приложение в аберрацию и множества ручек, ножек и голов в местах, непредназначенных для них.

Проблема - Игнорирование контрактов и высокая связность

Очень мало видел, чтобы хоть для модулей API создавались контракты (интерфейсы), а это очень важный пункт, но разберём важность этого пункта в решcениях.

Также увидим мощный пример с уменьшением связности.

Проблема - Создание функций, ответственных за 999 бизнес кейсов одной тематики

Нередки ситуации, когда разработчики обрабатывают очень много бизнес кейсов в одной функции, что в определенный момент сделает внесение изменений в логику без тотального рефакторинга невозможным или крайне трудозатратным (а еще разработчика будут вспоминать хорошими словами).

Проблема - Отсутствие комментариев

Практически каждый встречался при подключении на новый проект с ситуацией, когда смотришь на код, а он на тебя, и вы друг друга не понимаете неопределенное время.

Обычно, без человека, который не сядет с новым разработчиком и расскажет про этот код, можно долго "засесть" на одном месте и, что хуже, самому начать создавать баги, неверно интерпретируя имеющийся код.

Проблема - Отказ и(ли) отсутствие выделения времени на создание и поддержку тестирований

Учитывая, что NestJS позволяет удобно и из коробки, вместе с Jest, создавать моки и тестирования, как юниты, так и сквозные с интеграционными, много где я этих самых заветных тестов не видел.

Возможно, на задачу не выделили времени с написанием/поддержкой тестирований, опираясь на какие-то другие приоритеты. Возможно, связано с же нежеланием разработчиков этим заниматься, но проблема серьезная, которая, скорее всего, повлечет гораздо больше проблем и трудозатрат, чем кажется.

Проблема - Чрезмерное использование ORM

ORM - довольно удобная и приятная вещь, которая сильно упрощает разработку, но повальное использование таких инструментов во всех случаях приложения, как правило, вызывает проблемы с работой логики частей приложения.

Проблема - Неправильное управление исключениями

Исключения в NestJS - очень гибкая и удобная вещь, которую, к тому же, можно улучшить фильтрами. Игнорирование или неправильное использование исключение может неплохо усложнить работу с кодом.

Решаем проблемы и рефлексируем

В данном блоке мы разберем каждую проблему детальнее и посмотрим на предлагаемые мной решения оных, а также, автор поделится своим опытом.

Решение - Отсутствие абстрактного и(ли) графического (доменного) представления

Для решения данной проблемы мы можем обратиться к абстрагированию от кода и к любимому многими DDD.

В одно время я очень увлекался темой DDD, и понял, что главное - не то, куда и как расставлять файлы с папками, не агрегаты, а как решать проблемы на уровне доменов и понимать в разделении ответственностей по слоям, научиться распознавать контексты в элементах доменов.

Но вернемся к графическому и абстрактному представлению приложения. Я советую всем рисовать и представлять приложение схематически, абстрагируясь от кода.

Для примера представим, что мы делаем бекэнд для университета, и как мы его можем представить, чтобы архитектура была понятной, и разработчики могли следовать ей?

Вот простая схема, рассчитанная на минимальное количество места для компактности, но вам советую не экономить холст и тогда будет красиво и понятно.

Простейшая схема, нарисованная на draw.io, в реальности она будет на порядок больше.
Простейшая схема, нарисованная на draw.io, в реальности она будет на порядок больше.

Про слои ответственности. Лучшим примером будет использование кода поиска не из репозитория. Рассматривать будем на крохотных примерах, на которых можно не увидеть очень больших проблем, но нужные ассоциации они у вас вызовут, уверен, ведь обычно логика использования репозитория бывает намного сложнее.

Пример неправильного кода
// Сервис + репозиторий
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  async updateUserByIdAnd(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> {
    const formatedObject = MyUtils.formatObject(someUpdateObj);
    /* 
     * Еще какая-то логика...   
     */
    return this.userRepository.update({where: {id: userId}}, someUpdateObj);
  }
}

К сожалению, мы намешали разные слои и зоны ответственностей: сервис пользователей и что-то считает-вычисляет, выполняя часть бизнес задачи, да еще и выполняет функции инфраструктуры, сразу же засорив код сервиса, который вообще не должен общаться с базой данных напрямую, кроме редких случаев.

Для исправления этого нам стоит вынести всю логику инфраструктуры в методы UserRepository, и уже вызывать их, полностью вынося из сервиса инфраструктурную логику.

Пример правильного разделения по слоям
// Репозиторий
@Injectable()
export class UserRepository {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  async partialUpdateUserById(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> {
    //Можно использовать форматирование или выброс исключений, если мы определили репозиторий как умный (см. ниже)
      const formatedObject = MyUtils.formatObject(someUpdateObj);
      return this.userRepository.update({where: {id: userId}}, formatedObject);
  }
}
// Сервис
@Injectable()
export class UserService {
    constructor(
        private readonly userRepository: UserRepository,
    ) {}

    async updateUserByIdAnd(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> {
        /* 
         * Какая-то логика...   
         */
        return this.userRepository.partialUpdateUserById(userId, someUpdateObj);
    }
}

На выбор предлагается два вида репозиториев:

  • Умные (smart) репозитории полезны тогда, когда требуется выполнение операций типа форматирования и прочего, которые не могут быть выполнены просто с использованием CRUD операций.

  • Глупые (dumb) репозитории подходят для простых частей системы, когда от него требуется просто выполнить операции CRUD.

Теперь мы разделили ответственности слоёв и упростили код и читаемости приложения.

Контекст же нужно намечать, чтобы разработчики понимали, что и где, возможно зачем, связанны модули приложения, что упростит понимание последнего.

Еще довольно полезным умением, которым стоит научиться - уметь думать доменно и посредством кода, например: Отчислить студента = Добавить в журнал отчислений запись о данном студенте в таблице журнала отчислений базы данных, удалить/изменить запись студента в таблице студентов базы данных.

Решение - Игнорирование контрактов и высокая связность

Для начала приведу абзац теории:

Связность (coupling) в программной инженерии относится к степени зависимости между различными модулями или компонентами системы. Высокая связность означает, что компоненты сильно зависят друг от друга, что усложняет их изменение, тестирование и повторное использование. Низкая связность, напротив, предполагает, что компоненты имеют минимальные зависимости друг от друга, что делает систему более гибкой и легкой для поддержки. Контракты - описание интерфейсов взаимодействия компонентов, то есть, интерфейсы или классы DTO в TypeScript.

Если же сделать выводы в контексте NestJS, то для низкой связности части приложения не должны быть привязанным к интерфейсам определенных классов, и классы не должны быть источниками контрактов, а должны их имплементировать.

Простой пример интерфейсов в NestJS:

Пример
// Интерфейс
export interface IUserService {
    findAll(): Promise<User[]>;
    findOne(id: string): Promise<User>;
    create(createUserDto: CreateUserDto): Promise<User>;
}
// Сервис, его имплементирущий
@Injectable()
export class UserService implements IUserService {
  async findAll(): Promise<User[]> {
    // Логика получения всех пользователей
  }
  async findOne(id: string): Promise<User> {
    // Логика получения пользователя по ID
  }
  async create(createUserDto: CreateUserDto): Promise<User> {
    // Логика создания нового пользователя
  }
}

То есть, у нас не интерфейс(описание функциональности) зависит от имеющейся функциональности, а имеющаяся функциональность зависит от требуемой функциональности.

А как же это связанно со связностью?

Низкая связность достигается тем, что КлассА не зависит от конкретной реализации КлассаБ, а использует контракт, описанный для КлассаБ, и ему не важно, как КлассБ выполняет логику функциональности.

Со временем я нашел самый полезный вариант снижения связности для NestJS, и я его вам покажу:

Тот самый вариант
// Интерфейс сервиса
export interface IUserService {
    findAll(): Promise<User[]>;
    findOne(id: string): Promise<User>;
    create(createUserDto: CreateUserDto): Promise<User>;
}

// Интерфейс репозитория
export interface IUserRepository {
    findAll(): Promise<User[]>;
    findOne(id: string): Promise<User>;
    create(createUserDto: CreateUserDto): Promise<User>;
}

// Сервис
@Injectable()
export class UserService implements IUserService {
    constructor(
        @Inject('IUserRepository') private readonly userRepository: IUserRepository,
    ) {}
    async findAll(): Promise<User[]> {
        return this.userRepository.findAll();
    }
    async findOne(id: string): Promise<User> {
        return this.userRepository.findOne(id);
    }
    async create(createUserDto: CreateUserDto): Promise<User> {
        return this.userRepository.create(createUserDto);
    }
}

// Контроллер
@Controller('users')
export class UserController {
    constructor(@Inject('IUserService') private readonly userService: IUserService) {}
    @Get()
    async findAll() {
        return this.userService.findAll();
    }
    @Get(':id')
    async findOne(@Param('id') id: string) {
        return this.userService.findOne(id);
    }
    @Post()
    async create(@Body() createUserDto: CreateUserDto) {
        return this.userService.create(createUserDto);
    }
}

// Репозиторий
@Injectable()
export class UserRepository implements IUserRepository {
    constructor(
       @InjectRepository(User) private readonly userRepository: Repository<User>,
    ) {}
    async findAll(): Promise<User[]> {
        // Логика для получения всех пользователей
    }
    async findOne(id: string): Promise<User> {
        // Логика для получения пользователя по ID
    }
    async create(createUserDto: CreateUserDto): Promise<User> {
        // Логика для создания нового пользователя
    }
}

// Модуль
@Module({
    controllers: [UserController],
    providers: [
        {
            provide: 'IUserService',
            useClass: UserService,
        },
        {
            provide: 'IUserRepository',
            useClass: UserRepository,
        },
    ],
})
export class UserModule {
}

В данном примере мы получили максимально низкую связность, у каждого элемента есть свой контракт, который другие элементы получают и общаются посредством оного, а реализацию можно менять как и сколько угодно, главное, чтобы не нарушался контракт.

Компоненты взаимодействуют через интерфейсы, а не напрямую с конкретными реализациями, что уменьшает зависимость между ними.

При возможном изменении сервисов и/ли реализаций их логики, остальные элементы будут не затронуты без изменения контракта. Также, упрощает создание моков при тестировании, так как мы чётко знаем, что кому нужно.

Можно обойтись и без инжектов через токены, но в больших приложениях данная опция будет цениться для удобства больше.

Проблема - Создание функций, ответственных за 999 бизнес кейсов одной тематики

Допустим, есть метод:

Тот самый метод
async fetchData(userId: string): Promise<{user: User, shops?: Shops, shopsMoney?: ShopsMoney, myMoney: UserMoney}> {
    const user = this.userRepository.fetchUser(userId);
    const result = {};
    if (user.role === UserRoles.ADMIN) {
        result.shops = this.shopsRepository.findAll();
        result.shopsMoney = await Promise.all(ashops.map(async (shop) => {
                const money = await this.moneyRepository.findByShopId(shop.id);
                return {shop, money};
            }));
        return result;
    } else {
        result.user = user;
        result.money = await this.moneyRepository.findByUserId(user.id);
        return result;
    }
    // ...
}

Вот такой код я видел не один раз, и это еще я указал мало ролей и параметров, иногда вообще дремучий лес возникает в коде.

Что делать?

  1. Не допускать создания таких методов, разбивать их на единственные ответственности.

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

Решение - Отсутствие комментариев

Здесь решение довольно простое - начать писать комментарии, и я вас научу даже как.Есть такая прекрасная вещь, как JSDoc, и вот как можно писать комментарии на нем к функциям, методам и т.д.:

Пример
/**
 * @description Функция, которая добававляет пользователя в базу данных и отсылает ему на почту сообщение с приветствием
 * @param email - имейл пользователя
 * @param phone - (опционально) - телефон пользователя
 * @param name - ФИО пользователя
 * @returns {string} UUID пользователя
 * @link https://mysuperwiki.com/registerUser
 */
function registerUser(email: string, name: string, phone?: number): Promise<string> { ... }

Следуя данному примеру, мы можем получить описание функций, ссылку на вики, что возвращает функция. Еще неплохая функция JSDoc с ESLint, проверка на существование параметров, если нет соответствия, то последний будет сыпать варнингами.

 А еще получаем при наведении на функцию краткое описание
А еще получаем при наведении на функцию краткое описание

Еще рекомендую, но не настаиваю на описание шагов в важных и больших функциях, например:

Еще пример
function complicatedFunction(...) {
    // Шаг 1: Создаем x и вызываем функцию просчета траектории
    ...
    // Шаг 2: Передаем x в RMQ и ждем ответа
    ...
    // Шаг 3: Записываем результаты ответа от микросервиса просчета y
    ...
}

Поначалу, когда разработчик пишет еще горячий код, то потребность и желание в написании комментариев не так остра, а спустя некоторое время он смотрит на код со словами "Что же это такое?" - обыденная ситуация.

Решение - Отказ и(ли) отсутствие выделения времени на создание и поддержку тестирований

Наверное, самая важная и распространённая проблема, на самом деле. Про мотивы размышлять не будем, но тестирования, хотя бы юниты, обязаны быть. И лучше в юнитах добиваться хороших показателей покрытия, то есть, все возможные ветвления кода и т.д., но не усердствовать с неверным вводом, так как такие данные обсекутся пайпами и валидаторами.

В противном случае при малейших изменениях в больших приложениях, оно может потрескаться, и возможно даже совершенно в другом месте приложения, а узнают об этом только конечные пользователи, и начнется очередной виток из саппорта, тикета, правок, тестирования и прочего...

Лучше всего сделать обязательные тестирования хотя бы перед merge-реквестом.Тестирования - важнейшая часть приложения, на которую стоит потратить время, чтобы потом быть намного увереннее, что проблем будет намного меньше. А если еще добавить интеграционные и сквозные (е2е), то будет еще лучше.

Решение - Неправильное управление исключениями

И, наконец, хочу рассказать о надобностях исключений при создании логики приложения. Возможно, это кажется очевидным, но, как показывает практика, немало людей о них не знают или не хотят использовать.

Исключения, особенно в NestJS, очень удобные, но позволяют уменьшить количество кода, например:

Плохой пример
// Сервис 
@Injectable()
export class UserService {
    private users: User[] = [];
    findUserById(id: number): User | null {
        const user = this.users.find(user => user.id === id);
        return user || null;
    }
}
// Контроллер
@Controller('users')
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Get(':id')
    findUserById(@Param('id') id: number) {
        const user = this.userService.findUserById(id);
        if (!user) {
            return { message: 'Пользователь не найден!' }; // Лишний здесь код, но ответить пользователю нужно
        }
        return user;
    }
}

Это самый простой пример, когда мы можем избавиться от части кода и убрать все негативные результаты при выполнении.

Пример
// Сервис 
@Injectable()
export class UserService {
    private users: User[] = [];
    findUserById(id: number): User | null {
        const user = this.users.find(user => user.id === id);
        if (!user) {
            throw new NotFountException({ message: 'Пользователь не найден!' });
        }
        return user;
    }
}
// Контроллер
@Controller('users')
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Get(':id')
    findUserById(@Param('id') id: number) {
        return user = this.userService.findUserById(id);
    }
}

Как можно увидеть, мы уменьшили количество кода, не стали заниматься резолвом логики и даже получили возможность указывать, какой код NestJS отдаст пользователю.

Также можно и не обрабатывать результаты каких-то функций, а выбрасывать исключения в случаях, не являющимися правильными, прямо в самих функциях.

В таком случае у нас получаются либо только правильные ответы, либо ответы с ошибкой с кодом 4хх-5хх, что уменьшит количество ветвлений логики. Еще рекомендую добавить фильтры, которые добавляют дополнительную обработку исключений.

Какие выводы мы можем сделать

Вывод довольно прост - правильно выделять время на задачи, попросить ведущих разработчиков создать правила для написания кода, а что самое важное - внедрять людям культуру кода.

Культура кода важна для создания качественного, поддерживаемого и надежного программного обеспечения.

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

Но тут, как и в стандартной культуре, нужно постепенно прививать и пропагандировать, что это нужно, и, главное, почему это важно и нужно.

На этом всё, спасибо за прочтение данной статьи, если есть вопросы или что-то другое, то обязательно пишите в комментариях. Всем успехов в ваших делах!

Комментарии (11)


  1. SanoLitch
    01.07.2024 15:48
    +2

    Пример про связанность высосан из пальца…я раньше тоже городил интерфейсы на все подряд и кроме как боли это ничего не приносило. В целом ничего плохого не вижу для сервисов и репозиториев использовать конкретный класс как источник контракта между слоями за небольшим исключением - обычно это какие-нибудь классы-сервисы для обращения к третьим системам, отсылалки оповещений/сообщений и тп; у них может быть несколько однотипных реализаций, которые уже и инжектятся в целевой бизнесовый класс-сервис.

    Но я бы в целом не сказал, что пихание повсюду интерфейсов и создание бесконечного количества абстракций оказывает решающее влияние на связанность. Можно так завязать бизнес-логику на эти самые контракты, что будь то абстракция или реализация суть одна - каша (как обычно и бывает). Важнее грамотный дизайн модулей и четкое понимание как они должны взаимодействовать.


    1. Avangardio Автор
      01.07.2024 15:48

      Согласен, кашу можно наварить как угодно, но, в случаях использования контрактов можно получить ситуацию, когда человек, захотев прямо сразу менять контракт, может задуматься, что может отсеять плохие решения.

      А так, использовать или нет контракты - зависит от команды, как привыкли, или из-за чего проблемы возникают.


      1. Avangardio Автор
        01.07.2024 15:48

        Плюс, если есть БА(прямо как йети) в команде - при некоторых случаях изучения работы кода, контракты помогут.


      1. SanoLitch
        01.07.2024 15:48

         когда человек, захотев прямо сразу менять контракт, может задуматься, что может отсеять плохие решения

        Это навряд ли

        А вообще мой посыл в том, что не надо лепить интерфейсы и абстракции просто потому, что в умных книжках так написано.
        Абстракции должны изолировать потенциальные точки отказа приложения и обобщать взаимодействие с множеством реализаций. Кстати про умные книжки, давече читал "Эволюционная архитектура" и там как раз было про соотношение абстракций к реализациям - у авторов мнение (там есть какая-то формула для подсчета этого добра), что ок это примерно 1:3-1:4 соответственно, с чем я вообщем-то согласен.
        А пилить интерфесы вроде IUserService и IUserRepository ну максимально бессмысленно - других реализаций для них (почти 100%) не будет, поэтому смысла скрывать реализацию нет; смене контракта интерфейс не помешает; это даже не абстракция, просто интерфейс ради интерфейса


        1. Avangardio Автор
          01.07.2024 15:48

          Интересно узнать про соотношение, спасибо за рекомендацию)


  1. kellas
    01.07.2024 15:48
    +2

    Сама постановка задачи некорректная.

    Нет проектов которые легко поддерживать спустя годы, потому что с годами меняются люди и подходы в разработке. Об этом косвенно свидетельствует опыт многолетней работы программистов на каком-нибудь одном предприятии, где они как специалисты "консервируются" и у них нет проблем с поддержкой проекта который работает уже 10 лет, но есть проблемы с трудоустройством в другие компании работающие уже на современном стеке. Если же они параллельно с работой развиваются , то у них регулярно чешутся руки всё переписать.

    И наоборот, специалист с современными стеком вынужден ломать свои представления о разработке чтобы понять ту старую логику и те старые инструменты, которые в общем-то были хуже, труднее в использовании и т п

    То есть поддержка старого кода всегда будет тяжёлой задачей, смиритесь с этим, тут либо стагнация, либо боль от встречи даже с самим собой из прошлого выраженного в том "бывшем" коде.


    1. Avangardio Автор
      01.07.2024 15:48

      Привет, у меня следующее мнение: если мы сидим на стеке того же неста, то придется поддерживать только модули ручного кода, привязывать/отвязывать реализации на либерах, но именно с самим нестом я не видел серьезных изменений к подходу к работе в несте годами, так что некоторые проекты можно понять и что-то изменить с меньшей болью.


  1. Froncast
    01.07.2024 15:48

    Не пишу комментарии к какой-либо статье на хабре, потому что обычно в комментариях уже более опытные люди высказали свое мнение. Но тут у меня есть с чем не согласиться. Возможно какую-то информацию я не верно интерпретировал, додумал сам, принял всё близко к сердцу и т.д., потому и "родился" данный комментарий.

    1) Отсутствие абстрактного и(ли) графического (доменного) представления:
    Простая представленная схема. Возможно она действительно нарисованна просто для наглядности связи между модулями и я не разлядел в ней того как они на самом деле между собой связаны. Ибо глядя на эту схему я вижу "класические" модули из документации NestJS и когда модули вот так вот полностью импортируются один в другой это со временем начинает вызывать ряд проблем: связанность, в модули тянется то что им совершенно ненужно, из-за чего приложение стартует дольше.

    Набираясь опыта пришел к решению (а в последующем увидел в разных статьях и официальном курсе NestJS), что каждый модуль также делится на слои, где каждый слой это модуль. И если модулю Feature1 нужен модуль Feature2, то в него будет импортироваться только бизнес слой(модуль) из модуля Feature2.

    2) Игнорирование контрактов и высокая связность
    Про подключение провайдеров через интефейсы соглашусь, но в целом про модуль написано выше. Обойтись и без инжектов через токены можно используя абстрактные классы.

    Не знаю какую Вы закладывали логику для получения/создания пользователей, но так как Вы не написали пример то приходится додумывать самому что там написано. А потому могу сказалить лишь то, что максимум какая логика тут может быть описана, так это обработка входящих параметров (условия выборки, пагинация и т.д.) в зависимости от выбранного хранилища и клиента для него. При этом в контракте эти параметры должны быть описаны так, чтобы в любой момент можно было подменить как хранилище, так и клиента (сомневаюсь, что это частая практика и тем не менее, вдруг с TypeOrm решили перейти например на Kysely).

    3) Отсутствие комментариев
    С решением согласен, а вот пример функции тригернул.

    Функция, которая добававляет пользователя в базу данных и отсылает ему на почту сообщение с приветствием


    Ну не должна функция заниматься и созданием пользователя и отправкой уведомления. После создания пользователя лучше либо кинуть событие, на которое отрегарует соответствующий подписчик, либо в бизнес слое после создания пользователя отправляется уведомление.

    4) Неправильное управление исключениями.
    Вот тут крайне спорная ситуация. Если же с Вашим бекэндом общаются только через TCP, то решение кинуть HttpException в методе SomeService имеет место быть, иначе управление исключениями явно должно быть иное.