Разработка бэкенда сайта на C# (.NET / ASP.NET Core)
ASP.NET Core — зрелая платформа с предсказуемым поведением под нагрузкой, строгой типизацией и обширной экосистемой NuGet. Выбирают её, когда в команде уже есть .NET-инженеры, когда нужна интеграция с Active Directory / Azure AD, или когда требования к производительности слишком серьёзны для Node.js.
Когда .NET оправдан
Если API будет обрабатывать тысячи запросов в секунду с синхронными операциями к БД — ASP.NET Core на Kestrel без реверс-прокси легко держит 50–80k RPS на одном ядре. Если проект уже использует C# во фронтенде (Blazor) или в desktop-части — монорепо с единым языком снижает накладные расходы. Если нужен встроенный DI, OpenAPI, gRPC и WebSocket без шести отдельных библиотек.
Структура проекта
Минимальный API на .NET 8 без контроллеров:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
});
var app = builder.Build();
app.MapGet("/users/{id:int}", async (int id, IUserService svc) =>
await svc.GetByIdAsync(id) is { } user
? Results.Ok(user)
: Results.NotFound());
app.MapPost("/users", async (CreateUserDto dto, IUserService svc) =>
{
var user = await svc.CreateAsync(dto);
return Results.Created($"/users/{user.Id}", user);
});
app.Run();
Для проектов сложнее трёх сущностей лучше отдельные слои: Domain, Application, Infrastructure, WebApi. Vertical Slice Architecture с MediatR — хорошая альтернатива классическому layered-подходу:
// Features/Users/GetUser.cs
public static class GetUser
{
public record Query(int Id) : IRequest<UserDto?>;
public class Handler(AppDbContext db) : IRequestHandler<Query, UserDto?>
{
public async Task<UserDto?> Handle(Query req, CancellationToken ct) =>
await db.Users
.Where(u => u.Id == req.Id)
.Select(u => new UserDto(u.Id, u.Email, u.CreatedAt))
.FirstOrDefaultAsync(ct);
}
}
Entity Framework Core vs Dapper
EF Core с миграциями — стандарт для CRUD-тяжёлых сервисов. Dapper берут для отчётных запросов, где ORM генерирует неоптимальный SQL.
// EF Core — автоматические миграции
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<User>(e =>
{
e.HasIndex(u => u.Email).IsUnique();
e.Property(u => u.CreatedAt).HasDefaultValueSql("now()");
});
}
}
// Dapper — когда нужен сырой SQL
public async Task<IEnumerable<ReportRow>> GetReportAsync(DateRange range)
{
await using var conn = new NpgsqlConnection(_connStr);
return await conn.QueryAsync<ReportRow>("""
SELECT date_trunc('day', created_at) AS day,
count(*) AS orders,
sum(total) AS revenue
FROM orders
WHERE created_at BETWEEN @From AND @To
GROUP BY 1
ORDER BY 1
""", new { range.From, range.To });
}
Кэширование и фоновые задачи
Output Cache на .NET 8 — декларативное кэширование ответов прямо на эндпоинте:
builder.Services.AddOutputCache(opt =>
{
opt.AddPolicy("products", p => p.Expire(TimeSpan.FromMinutes(10)).Tag("products"));
});
app.MapGet("/products", async (IProductRepo repo) => await repo.GetAllAsync())
.CacheOutput("products");
// Инвалидация после изменений
app.MapPost("/products", async (CreateProductDto dto, IOutputCacheStore cache, ...) =>
{
var product = await svc.CreateAsync(dto);
await cache.EvictByTagAsync("products", CancellationToken.None);
return Results.Created($"/products/{product.Id}", product);
});
Фоновые задачи через IHostedService или Hangfire:
// Простая фоновая задача
public class EmailWorker(IServiceProvider sp) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = sp.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<IEmailQueue>();
var mailer = scope.ServiceProvider.GetRequiredService<IMailer>();
await foreach (var msg in queue.DequeueAsync(ct))
await mailer.SendAsync(msg, ct);
}
}
}
SignalR для реального времени
public class NotificationHub : Hub
{
public async Task JoinGroup(string groupName) =>
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}
// Отправка из сервиса
public class OrderService(IHubContext<NotificationHub> hub)
{
public async Task UpdateStatusAsync(int orderId, string status)
{
await _db.Orders.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, status));
await hub.Clients.Group($"order-{orderId}")
.SendAsync("StatusChanged", new { orderId, status });
}
}
Деплой
Dockerfile для production:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
Health checks для оркестраторов:
builder.Services.AddHealthChecks()
.AddNpgsql(connStr, name: "postgres")
.AddRedis(redisConn, name: "redis");
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready");
Сроки
Простой REST API (5–10 эндпоинтов, одна БД, JWT-аутентификация): 5–8 рабочих дней. Полноценный бэкенд с ролями, фоновыми задачами, WebSocket, интеграционным тестированием и CI/CD: 3–5 недель. Миграция существующего .NET Framework проекта на .NET 8 зависит от глубины legacy — оцениваем отдельно после аудита кодовой базы.







