Why Most .NET Backends Are Harder to Maintain Than They Should Be
A well-set-up ASP.NET Core project has three things that most quickly-built backends are missing: Swagger configured with Bearer token support so you can actually test protected endpoints, services injected via interfaces so unit tests can mock them, and [Authorize] attributes applied at the controller level rather than only in the frontend.
Each of these is straightforward once you know the pattern. Together they give you a backend that is documented, testable, and secure — and that stays that way as the project grows.
Setting Up Swagger With Bearer Token Authorization
The default Swagger setup that gets generated by the ASP.NET Core template gives you endpoint documentation. It does not give you a way to test endpoints that require a JWT. The moment you add [Authorize] to a controller, every call from Swagger UI returns 401 — making the tool almost useless for your most important endpoints.
The fix is to configure Swagger to include a security definition for Bearer tokens. Add this to Program.cs:
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "ASP.NET Core Web API"
});
// Define the Bearer auth scheme
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT access token. Example: Bearer {token}"
});
// Require Bearer on all endpoints by default
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
With this in place, the Swagger UI shows an "Authorize" button at the top. You paste your JWT (obtained from your login endpoint), click Authorize, and every subsequent call from the UI sends the token in the Authorization header — exactly as a real client would.
This means you can test your entire API — including protected routes — from the browser with no Postman setup required. For development speed and onboarding new developers, this is significant.
Also make sure the middleware pipeline in Program.cs has Swagger in the right order:
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API v1"));
}
app.UseAuthentication(); // must come before UseAuthorization
app.UseAuthorization();
app.MapControllers();
UseAuthentication must come before UseAuthorization — this is a common mistake that causes 401 responses even when the token is valid.
Interface-Driven Services — The Foundation of Testable Code
A controller that directly instantiates a concrete service class cannot be unit tested without spinning up a real database, real HTTP clients, or real external dependencies. The solution is to inject services via interfaces, so tests can substitute a mock implementation.
Here is the pattern:
// 1. Define the interface
public interface IUserService
{
Task<User?> GetByIdAsync(Guid id);
Task<IEnumerable<User>> GetAllAsync();
Task<User> CreateAsync(CreateUserRequest request);
Task DeleteAsync(Guid id);
}
// 2. Implement it
public class UserService : IUserService
{
private readonly AppDbContext _db;
public UserService(AppDbContext db) => _db = db;
public async Task<User?> GetByIdAsync(Guid id)
=> await _db.Users.FindAsync(id);
// ... other implementations
}
// 3. Register in Program.cs
builder.Services.AddScoped<IUserService, UserService>();
The controller receives the interface, not the concrete type:
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id)
{
var user = await _userService.GetByIdAsync(id);
return user is null ? NotFound() : Ok(user);
}
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
var user = await _userService.CreateAsync(request);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
}
Writing Unit Tests Against the Interface
With the interface in place, your xUnit tests can mock the service entirely — no database, no real HTTP calls, no external dependencies:
public class UsersControllerTests
{
private readonly Mock<IUserService> _mockService;
private readonly UsersController _controller;
public UsersControllerTests()
{
_mockService = new Mock<IUserService>();
_controller = new UsersController(_mockService.Object);
}
[Fact]
public async Task GetUser_ReturnsOk_WhenUserExists()
{
// Arrange
var userId = Guid.NewGuid();
var fakeUser = new User { Id = userId, Email = "test@example.com" };
_mockService
.Setup(s => s.GetByIdAsync(userId))
.ReturnsAsync(fakeUser);
// Act
var result = await _controller.GetUser(userId);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var returned = Assert.IsType<User>(okResult.Value);
Assert.Equal(userId, returned.Id);
}
[Fact]
public async Task GetUser_ReturnsNotFound_WhenUserDoesNotExist()
{
// Arrange
var userId = Guid.NewGuid();
_mockService
.Setup(s => s.GetByIdAsync(userId))
.ReturnsAsync((User?)null);
// Act
var result = await _controller.GetUser(userId);
// Assert
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public async Task CreateUser_ReturnsCreatedAtAction()
{
// Arrange
var request = new CreateUserRequest { Email = "new@example.com" };
var newUser = new User { Id = Guid.NewGuid(), Email = request.Email };
_mockService
.Setup(s => s.CreateAsync(request))
.ReturnsAsync(newUser);
// Act
var result = await _controller.CreateUser(request);
// Assert
var created = Assert.IsType<CreatedAtActionResult>(result);
Assert.Equal(nameof(_controller.GetUser), created.ActionName);
}
}
These tests run in milliseconds and can run in CI with no infrastructure. They test the controller logic — routing, response codes, response shape — without touching the service implementation. Your service implementation gets its own set of integration tests that do hit a database.
Bearer Token and the [Authorize] Attribute
Swagger with Bearer support is only useful if your endpoints are actually protected. Hiding a route in the frontend is not authorization — any developer with a REST client can bypass the UI entirely.
Every endpoint that accesses user data or performs sensitive operations needs the [Authorize] attribute at the controller or action level:
// Protect the whole controller — every action requires a valid token
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UsersController : ControllerBase
{
// This action inherits [Authorize] from the controller
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetUser(Guid id) { ... }
// Admin-only endpoint — role enforced at the API layer, not the UI
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id)
{
await _userService.DeleteAsync(id);
return NoContent();
}
// Public endpoint — explicitly opt out of auth
[AllowAnonymous]
[HttpGet("public-profile/{id:guid}")]
public async Task<IActionResult> GetPublicProfile(Guid id) { ... }
}
For more granular rules, use policy-based authorization defined in Program.cs:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("SeniorDeveloperOnly", policy =>
policy.RequireRole("Developer")
.RequireClaim("seniority", "senior", "lead", "principal"));
});
// On the controller action
[Authorize(Policy = "SeniorDeveloperOnly")]
[HttpPost("deployments")]
public async Task<IActionResult> TriggerDeployment(DeploymentRequest request) { ... }
The Complete Program.cs Setup
Here is a complete Program.cs that wires everything together — JWT authentication, Swagger with Bearer support, and interface registrations:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Controllers
builder.Services.AddControllers();
// Swagger with Bearer token support
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter: Bearer {your JWT token}"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
// JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!)
),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
// Service registrations via interfaces
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITokenService, TokenService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication(); // Order matters — auth before authz
app.UseAuthorization();
app.MapControllers();
app.Run();
Why This Setup Pays Off
This pattern — Swagger with Bearer support, interface injection, and [Authorize] at the controller level — is not gold-plating. It directly solves real problems:
- Development speed: You can test every endpoint, including protected ones, directly from the Swagger UI without configuring Postman or writing curl scripts.
- Confidence: Unit tests with mocked interfaces run in milliseconds in CI. You know controller logic is correct before the code ships.
- Security: Authorization at the API layer means the backend is protected regardless of what the frontend does or does not do.
- Maintainability: When requirements change, you update the interface and its implementation. Everything that depends on the interface — including tests — still compiles cleanly.
I apply this setup on every .NET backend project I take on. If you are starting a new ASP.NET Core API or inheriting one that does not have this structure yet, these are the three things worth setting up first.
If you are setting up a new ASP.NET Core project, see the full list of .NET backend development services I offer, or get in touch directly.