Архітектура тревел-застосунку на ASP.NET Core: відмова від мікросервісів та обробка геоданих
Привіт, спільното DOU! Я — CEO та фаундер стартапу DaliWay.
Коли ти кажеш друзям «я роблю сервіс для подорожей», найчастіша реакція: «Навіщо? Є ж Google Maps, TripAdvisor та Instagram». Але якщо ви хоч раз намагалися організувати поїздку компанією у месенджері, то знаєте, що це пекло: посилання тонуть у повідомленнях, відгуки в Google часто виявляються накрученими, а замість логічної прогулянки ви намотуєте 14 кілометрів хаотичної біганини. І врешті, після всіх цих складнощів, ви повертаєтеся додому з купою фото, але викладаєте лише декілька з них. А через рік ви вже й забудете, де саме ходили і як називалися ті цікаві місця.
Я вирішив створити інструмент, який поєднує три речі:
- Пізнавай — карта місць із жорсткою GPS-верифікацією візитів (анти-фейк).
- Плануй — спільні колекції точок, інтегровані з груповими чатами та конструктором маршрутів.
- Зберігай — особистий трекер (календар, карта світу).
У цій статті я хочу поділитися тим, що під капотом нашого бекенду на ASP.NET Core. Я розповім, чому свідомо відмовився від мікросервісів, як обходиться обмеження Entity Framework Core для ізоляції модулів, та як ми оптимізуємо роботу з картою через PostGIS.
Архітектура: Чому Модульний Моноліт і як ізолювати EF Core
Для стартапу на ранній стадії мікросервіси — це величезний оверхед на інфраструктуру, CI/CD, боротьбу з мережевими затримками та складними налаштуваннями синхронізації й доступів. Я обрав Модульний Моноліт (Modular Monolith).
Проте головна проблема монолітів — це поступове перетворення коду на непролазні джунглі. Особливо на рівні бази даних, коли один модуль починає прямо керувати таблицями іншого, утворюючи жорсткі Foreign Keys.
Щоб цього уникнути, я застосував концепцію Read-only сутностей. Якщо модулю Posts потрібна інформація про локації з модуля Places, він не має прямого посилання на доменну сутність Place. Натомість створюються окремі ReadonlyEntity, які мапляться на ті самі таблиці, але виключаються з EF Core міграцій поточного модуля.
Ось як виглядає налаштування у модулі Posts:
C#
public static class PlacesConfiguration
{
public static ModelBuilder ConfigurationPlaces(this ModelBuilder builder)
{
// Модуль Posts читає таблицю Places, але НЕ керує її схемою
builder.Entity<PlaceReadonlyEntity>(entity =>
{
entity.ToTable("Places", t => t.ExcludeFromMigrations());
// Інші налаштування сутності
});
return builder;
}
}
Чому це важливо технічно?
ExcludeFromMigrations()гарантує, що при генерації міграції для модуляPostsEF Core не намагатиметься створити таблицюPlacesабо переписати її ключі.- Зберігається можливість робити звичайні SQL
JOINна рівні бази даних (що працює в десятки разів швидше, ніж gRPC/HTTP-виклики між мікросервісами). - Якщо в майбутньому ми вирішимо винести
Placesв окремий сервіс, доведеться переписати лише рівень отримання даних дляPlaceReadonlyEntity, не чіпаючи бізнес-логікуPosts.
Для мапінгу цих сутностей на доменні моделі (у рамках DDD) був обраний Mapster, оскільки він швидкий, генерує код на етапі компіляції і має лаконічний синтаксис. Але що найважливіше — ми налаштували мапінг Read-only сутностей як односторонній, що дає додаткову архітектурну впевненість у тому, що буде лише отримання цих сутностей:
C#
public partial class EntitiesMapping
{
private void CreateMapPlaces(TypeAdapterConfig config)
{
config.NewConfig<PlaceReadonlyEntity, PlaceEntityModel>()
.Map(dest => dest.Longitude, src => src.Location.X)
.Map(dest => dest.Latitude, src => src.Location.Y);
}
}
Гео-дані: PostGIS та просторові індекси
Оскільки серцем DaliWay є карта, реляційна алгебра працює в парі з геометрією. Для цього використовується PostgreSQL + PostGIS (через інтеграцію NetTopologySuite).
Замість того, щоб зберігати координати просто як два поля float8, використовується тип geography(Point, 4326). Географія (сфераоїд WGS 84), на відміну від пласкої геометрії, дозволяє рахувати точну відстань у метрах, враховуючи кривизну Землі. Це критично для анти-фейк системи та радіусних пошуків.
C#
builder.Entity<PlaceEntity>(entity =>
{
entity.HasKey(x => x.Id);
// GIN-індекс + pg_trgm для надшвидкого повнотекстового пошуку назв локацій
entity.HasIndex(e => e.Name)
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// GIST-індекс для гео-просторових обчислень (пошук у радіусі)
entity.HasIndex(e => e.Location)
.HasMethod("GIST");
entity.Property(e => e.Location)
.HasColumnType("geography(Point, 4326)");
});
Динамічна кластеризація карти «на льоту» засобами БД
Виведення 10 000 точок на мобільний клієнт — це смерть для оперативної пам’яті та мережі. Деякі проєкти тягнуть всі координати на бекенд і кластеризують їх у пам’яті сервера (через KD-дерева). Я ж вирішив скинути цю роботу на базу даних.
Залежно від зуму карти (радіусу огляду), розраховується розмір віртуальної сітки (gridLat, gridLng) і ми групуємо координати за допомогою Math.Floor. Суть у тому, що EF Core вміє транслювати ці математичні операції у синтаксис SQL, виконуючи агрегацію безпосередньо в Postgres:
C#
public Task<List<MapClusterModel>> GetMapClustersAsync(IPlacesFilterSchema schema, int authUserId)
{
var targetLocation = schema.GetTargetLocationOrDefault();
var (gridLat, gridLng) = GetGridLocation(schema); // Визначаємо крок сітки
return query
// Фільтруємо точки тільки в радіусі огляду користувача
.Where(p => p.Location.IsWithinDistance(targetLocation, schema.RadiusInMeters!.Value))
// Угруповання прямо в БД — EF Core згенерує відповідний SQL
.GroupBy(p => new
{
CellLat = Math.Floor(p.Latitude / gridLat),
CellLng = Math.Floor(p.Longitude / gridLng)
})
.Select(g => new MapClusterModel
{
Latitude = g.Average(p => p.Latitude), // Координата центру кластера
Longitude = g.Average(p => p.Longitude),
PlacesCount = g.Count() // Скільки місць увійшло в кластер
})
.ToListAsync();
}
Під кінець: наші перспективи
Сьогодні бекенд DaliWay — це продуктивний моноліт, який витримує складну гео-математику та дозволяє швидко ітерувати продукт. Вже у системі є локалізації 10 мовами, вбудуваний месенджер для спільного планування поїздок та реалізований конструктор маршрутів на карті. Усе це в парі з архітектурою бекенду працює швидко, навіть з понад мільйоном місць у системі.
Головний продуктовий виклик зараз — проблема «холодного старту» у нових містах. Цей ризик опрацьовується утилітарністю: навіть на порожній карті застосунок працює як зручний персональний трекер та інструмент оптимізації маршрутів (замість хаосу в Telegram).
Буду радий почути фідбек спільноти DOU щодо архітектури, ізоляції модулів через read-only таблиці та роботи з просторовими даними. Запитання, критику та альтернативні ідеї чекаю в коментарях!
Сайт проєкту: daliway.com
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівДобрий день.
Бажаю вашому стартапу вийти в прод і стати успішним!
Наразі мені здаеться ви реалізовали ідею модульного на половину.
Якщо вам колись прийдеться відокремлювати модуль — вам прийдеться виправляти всі ці джойни, які ви зараз для швидоксті додасте.
Тут треба або робити проекції даних — для швидкості. Або ходити між модулей через grpc/http.
Мені було б цікаво подивитись на архітектуру вашого додатку.
Зі свого боку мені подобаеться вводити певну ізолюцію модулей на рівні різних схем.
Але модульний моноліт — це для проекту який вже має кліентів. Якщо тестувати теорію — краще взяти моноліт і підписку на клод побільше. Неважливо як класно спроектован бек — якщо кліентів можно порахувати на пальцях однієї руки :)
Как выглядит класс
Он связан с PlaceEntity?
Когда происходит миграция PlaceEntity, что происходит с Readonly?
Все это ради чего, чтобы случайно не поменять данные Places в модуле Posts?
Не бачу у публікації архітектури застосунку.