Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 100 / 100

100 days of .NET: what I learned — a step-by-step guide

Wrapping up the series — and what comes next.

Expona Admin7 min read

100 days of .NET: what I learned

A hundred days ago, we started writing about .NET and ASP.NET Core with one simple goal: share what we've learned building production systems across e-commerce, CRM, trading and SaaS. Today, we're wrapping up the series—and the lessons have been surprising. It turns out that mastering .NET isn't about chasing the latest packages or architectural trends; it's about understanding the platform deeply, respecting its constraints, and knowing when simplicity wins.

This post isn't a retrospective of individual articles. Instead, it's a reflection on the patterns that emerged across 100 days of hands-on engineering, testing theory against reality, and learning what actually works in production.

The platform is mature—most problems already have a well-designed solution

One of the hardest lessons to learn is that .NET has already solved most of the problems we encounter. Dependency injection, configuration management, middleware pipelines, Entity Framework Core, logging, health checks, options patterns—these aren't nice-to-haves. They're thoughtfully designed, battle-tested parts of the framework.

The instinct to reach for a NuGet package before understanding what the framework offers has cost us time on more than one project. We've seen teams add AutoMapper before learning about EF Core projections, import Hangfire for background jobs without trying the native BackgroundTaskQueue pattern, or pull in custom validation libraries when data annotations and IValidatableObject handle 80% of use cases.

Learning the framework first isn't conservative—it's pragmatic. You understand the ecosystem, the conventions, and the limits. Only then do you know which problems actually need external solutions.

Consider configuration. The built-in IConfiguration and options pattern handle environment-specific settings, strongly-typed access, and validation:

csharp
public class DatabaseOptions
{
    public string ConnectionString { get; set; }
    public int CommandTimeout { get; set; }
    public int MaxPoolSize { get; set; }
}

// In Program.cs
builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection("Database"));

// In your service
public class UserRepository
{
    private readonly IOptions<DatabaseOptions> _options;
    
    public UserRepository(IOptions<DatabaseOptions> options)
    {
        _options = options;
    }
    
    public async Task<User> GetByIdAsync(int id)
    {
        using var connection = new SqlConnection(_options.Value.ConnectionString);
        // ...
    }
}

This is type-safe, unit-testable, and requires no external dependencies. Many teams add a third-party configuration library when this pattern already solves their problem.

Simplicity scales—minimal APIs, a modular monolith, and EF Core carry further than expected

We've built systems expecting we'd need microservices, event sourcing, CQRS, and complex orchestration. In almost every case, a well-structured monolith with Entity Framework Core and Minimal APIs got us further than we anticipated.

Minimal APIs remove ceremony without sacrificing clarity. No controller classes, no attribute routing arguments—just route definition and handler logic side by side:

csharp
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddScoped<IUserService>, UserService>();
builder.Services.AddDbContext<ApplicationDbContext>();

var app = builder.Build();

app.MapGet("/users/{id}", async (int id, IUserService userService) =>
{
    var user = await userService.GetByIdAsync(id);
    return user is null ? Results.NotFound() : Results.Ok(user);
})
.WithName("GetUserById")
.WithOpenApi();

app.MapPost("/users", async (CreateUserRequest request, IUserService userService) =>
{
    var userId = await userService.CreateAsync(request);
    return Results.CreatedAtRoute("GetUserById", new { id = userId });
})
.WithName("CreateUser")
.WithOpenApi();

app.Run();

This approach scales to hundreds of endpoints. A modular monolith—organizing code by feature rather than layer—keeps the codebase navigable. You have clear boundaries, shared infrastructure, and no network calls between modules. When (not if) you need to extract a service later, you have a clean contract.

Entity Framework Core continues to be underestimated. Its query compilation, change tracking, and integration with Minimal APIs handle CRUD, reporting, and complex queries without custom data access layers in most cases. Developers often reach for a micro-ORM or raw SQL because they don't understand EF Core's capabilities.

Performance is a habit—small practices, significant results

Performance isn't a feature you add at the end. It's a set of habits built into how you write code every day.

NoTracking queries: If you're not modifying an entity, disable change tracking. It's a free performance win:

csharp
// Tracking (default) — EF Core maintains state
var users = await context.Users.ToListAsync();

// NoTracking — much faster for read-heavy queries
var users = await context.Users.AsNoTracking().ToListAsync();

Projections over full entity loads: Select only the columns you need:

csharp
// Bad: loads entire User entity with all relations
var users = await context.Users.ToListAsync();
var result = users.Select(u => new { u.Id, u.Name }).ToList();

// Good: only fetches Id and Name from the database
var result = await context.Users
    .Select(u => new { u.Id, u.Name })
    .ToListAsync();

Async patterns: Use async I/O everywhere—database, HTTP, file access. The framework is built for it. But async is only correct when the underlying operation is actually asynchronous. Wrapping synchronous work in Task.Run on the request thread wastes resources.

Caching: Distributed caching with Redis or in-process caching with IMemoryCache handles hot paths. The key is knowing what to cache and for how long. Cache invalidation strategy matters as much as the cache itself.

csharp
public class CachedUserService
{
    private readonly IUserRepository _repository;
    private readonly IMemoryCache _cache;
    
    public async Task<User> GetByIdAsync(int id)
    {
        var cacheKey = $"user_{id}";
        
        if (_cache.TryGetValue(cacheKey, out User cachedUser))
        {
            return cachedUser;
        }
        
        var user = await _repository.GetByIdAsync(id);
        
        _cache.Set(cacheKey, user, TimeSpan.FromMinutes(5));
        return user;
    }
}

These aren't innovations—they're baseline practices. Applied consistently, they deliver 2-5x performance improvements without complex infrastructure.

Writing made me better—explaining concepts daily exposed gaps in my own understanding

Writing a technical article every day for 100 days forced clarity. You can't write about a concept you don't fully understand. Readers ask questions you can't answer, spot oversimplifications, and challenge assumptions.

The exercise of explaining async/await, dependency injection, EF Core performance, or middleware pipelines to an audience revealed how much we take for granted. It's easy to use a tool correctly without understanding why it works. Writing forces that distinction.

This is why we encourage teams to document decisions, write runbooks, and explain tradeoffs internally. It's not bureaucracy—it's the fastest way to sharpen your team's understanding.

Common mistakes

  • Over-engineering early: Implementing CQRS or event sourcing before you have a concrete problem they solve. Start simple, extract patterns when pain appears.
  • Ignoring async: Mixing sync and async code, blocking on async calls with .Result or .Wait(), or using Task.Run to avoid learning proper async patterns.
  • Skipping projections: Loading entire entities and filtering in memory instead of pushing the filter down to the database.
  • Logging everything: Structured logging is powerful, but logging every line of execution noise kills performance and makes logs useless. Log state changes, errors, and timing—not routine operations.
  • Avoiding the framework: Building custom patterns for problems the framework already solves well. Validate against framework conventions before adding layers.

Keep going—what comes next

The articles from this series are live on expona.dev. They cover Minimal APIs, Entity Framework Core patterns, dependency injection, async/await, caching, middleware, security, and deployment to AWS. They're designed as a reference—something you return to when you hit a specific problem.

We're planning deeper series on specific topics: .NET for systems programming, advanced database patterns, securing API infrastructure, and building resilient distributed systems with WebSockets and streaming. We're also exploring how .NET integrates with the infrastructure we build at Expona—secure tunnels, edge networking, and remote file sharing.

We want to hear what matters to your team. Which topics should we go deeper on? What problems are you hitting that you'd like us to work through publicly?

Key takeaways

  • Master the framework before adding dependencies. Most problems already have well-designed solutions built in.
  • Simplicity scales further than we typically expect. A modular monolith with Minimal APIs and EF Core handles most production workloads without exotic architecture.
  • Performance is a habit, not a phase. NoTracking queries, projections, proper async, and targeted caching compound into significant gains.
  • Writing forces understanding. If you can't explain a concept clearly, you don't fully understand it. Use that feedback to sharpen your knowledge.
  • 100 days is a starting point. The work of building better systems—learning from mistakes, refining patterns, sharing knowledge—continues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles