Most business software involves a familiar set of problems. You need to organise data, enforce rules, expose an API, talk to other services and keep work moving when a customer closes their browser. Then you need to change all of it as the business develops.
C# and .NET are my usual starting point for that work. C# is the language; .NET supplies the runtime and libraries, with ASP.NET Core handling the web application. Together, they’re an unusually practical foundation for SaaS products, web apps and dashboards.
I think they’re also frequently overlooked. Some of that is Microsoft bias. Some is a reputation left over from early .NET: Windows-only, closed source, heavyweight, slow and awkward to work with. Developers formed an opinion, put the stack in a box and stopped looking.
The software moved on.
Microsoft began opening up .NET Core in 2014. The 1.0 release in June 2016 brought the new runtime, ASP.NET Core and EF Core to Windows, macOS and Linux. Modern .NET is open source throughout the core stack. You can read the implementation, build it yourself and deploy it without buying a Windows licence.
I’ve never worked with Windows servers in my 25 years of professional development. Choosing C# doesn’t require me to start.
Here’s why I like it, with more code than I usually include here. The examples use an imaginary invoicing product. They show individual pieces of an application, with the surrounding setup described where it matters.
A language that makes business rules pleasant to write
There’s a connection many TypeScript developers miss: Anders Hejlsberg, TypeScript’s lead architect, was also C#’s original designer. If you enjoy TypeScript’s tooling and attention to types, C# deserves a look.
The difference is what sits underneath. TypeScript deliberately preserves JavaScript’s runtime behaviour; its types are erased. C# works with a runtime that understands its types, interfaces and generics. Libraries can use that information while the program runs, which becomes especially useful for databases, dependency injection and serialisation.
I particularly like the small language features that make intent obvious. Consider a request to create an invoice:
public sealed record CreateInvoice
{
public required string CustomerName { get; init; }
public required decimal Amount { get; init; }
}
var request = new CreateInvoice
{
CustomerName = "Northstar Books",
Amount = 149.50m
};
Leave out CustomerName or Amount when constructing this object and the compiler complains. init limits normal assignment to construction, while decimal is a natural fit for base-ten amounts. That gives me a clear structure for the request, ready for the application’s currency, amount and rounding rules.
The required modifier arrived in C# 11, alongside raw string literals and list patterns. Raw strings make a JSON test fixture readable without escaping every quotation mark:
var fixture = """
{
"customerName": "Northstar Books",
"amount": 149.50
}
""";
string[] permissions = { "invoices", "read" };
bool canRead = permissions is ["invoices", "read"];
C# keeps getting better at the things I use it for. Each release gives me more ways to express what the application should do, with less code and more help from the compiler. The language has kept moving as software has changed, and some of the additions coming this November are particularly useful for business applications.
Rust-like unions
One feature I’m looking forward to is native unions in C# 15, coming with .NET 11 this November. They’re already available in preview. If you’ve used Rust’s enums to represent different outcomes, the idea will feel familiar.
Take a payment. It might succeed, be declined or need another step from the customer. Each outcome needs different information:
public sealed record Paid(string ReceiptId);
public sealed record Declined(string Reason);
public sealed record NeedsAction(Uri RedirectUrl);
public union PaymentResult(Paid, Declined, NeedsAction);
Now a method can return PaymentResult, and the caller knows which outcomes to handle. A successful payment carries a receipt ID. A declined payment carries a reason. We don’t need a bag of nullable properties and a string status that might disagree with them.
static string Describe(PaymentResult result) => result switch
{
Paid paid => $"Receipt: {paid.ReceiptId}",
Declined declined => $"Declined: {declined.Reason}",
NeedsAction action => $"Continue at {action.RedirectUrl}"
};
PaymentResult result = new Paid("receipt-1042");
Console.WriteLine(Describe(result));
The compiler checks the allowed types and whether the switch covers every case. Returning an unrelated type is an error. Leave out NeedsAction and you get a warning; treating warnings as errors makes that a failed build. Add another outcome later and the compiler points out switches that need updating, provided you haven’t hidden the missing case behind a catch-all branch.
That’s what I like about Rust’s enums: the model helps me find the work a change creates. C# is gaining the same useful way to describe alternatives and check that I’ve handled them.
You can try this now with the .NET 11 preview SDK and LangVersion set to preview. The other framework examples in this article use .NET 10.
EF Core makes the database part of the application
Entity Framework Core is an object-relational mapper: it translates between database rows and C# objects. That description undersells how much useful work it does.
For our invoicing example, the model starts as ordinary classes:
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Invoice> Invoices { get; set; } = new();
}
public sealed class Invoice
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public decimal Amount { get; set; }
public DateTimeOffset DueAt { get; set; }
public bool Paid { get; set; }
}
CustomerId represents the foreign key. Customer and Invoices represent the relationship in each direction. The null! tells the compiler that EF will populate this navigation property when it is loaded; it doesn’t load the customer by itself.
A DbContext describes the database session and lets us configure details such as monetary precision:
public sealed class BillingDb : DbContext
{
public BillingDb(DbContextOptions<BillingDb> options)
: base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Invoice>()
.Property(i => i.Amount).HasPrecision(18, 2);
}
}
These examples need EF Core and a database provider. My usual database is PostgreSQL, with Npgsql’s EF Core provider. I configure indexes, constraints and precision around the product’s queries and business rules, keeping those decisions alongside the model they support.
Database changes become reviewable migrations
When I change the model, EF can generate the corresponding schema migration:
dotnet ef migrations add AddInvoiceDueDate
dotnet ef migrations script --idempotent
The first command compares the current model with the previous model snapshot. The second produces a script that checks migration history so it can apply missing migrations. EF’s migration documentation explains how this keeps the database schema evolving with the application.
That gives me a database change I can review alongside the application change. I still check the generated operations and test them against representative data. A rename can be mistaken for dropping one column and adding another, so reviewing the migration is part of the job, especially when existing customer data is involved.
The model, queries and tracked updates work together in the same language. I can follow a business change from its C# definition through to the database migration in one review.
LINQ lets a query read like a query
LINQ stands for Language Integrated Query. It lets me express filtering, sorting, joins and projections using C# syntax. Here’s a list of overdue invoices, including each customer’s name:
var now = DateTimeOffset.UtcNow;
var overdue = await (
from invoice in db.Invoices
join customer in db.Customers
on invoice.CustomerId equals customer.Id
where !invoice.Paid && invoice.DueAt < now
orderby invoice.DueAt
select new
{
invoice.Id,
CustomerName = customer.Name,
invoice.Amount
}
).Take(50).ToListAsync(ct);
Or I can use method syntax and follow the mapped relationship:
var overdue = await db.Invoices
.Where(i => !i.Paid && i.DueAt < now)
.OrderBy(i => i.DueAt)
.Select(i => new
{
i.Id,
CustomerName = i.Customer.Name,
i.Amount
})
.Take(50)
.ToListAsync(ct);
These are alternative ways to express the same result for this required relationship. EF translates the query for the database provider, including the join needed for Customer.Name. It doesn’t fetch every invoice and customer into memory before filtering. Execution happens at ToListAsync; ct is a cancellation token.
The result has real types: an integer ID, a string customer name and a decimal amount. Rename a property or try to compare incompatible types and the compiler helps identify the affected code. You get editor completion across relationships without manually declaring a second result shape.
For a screen that needs whole objects, eager loading can populate the relationship itself:
var invoice = await db.Invoices
.Include(i => i.Customer)
.SingleOrDefaultAsync(i => i.Id == invoiceId, ct);
string? customerName = invoice?.Customer.Name;
For a dashboard, I usually prefer a projection like the earlier query: fetch the columns the screen actually uses.
Expression trees are the part JavaScript doesn’t give you
This is where C# gets interesting. A lambda can represent executable code, but it can also become an expression tree: a typed description of that code which another library can inspect.
Expression<Func<Invoice, bool>> unpaid = i => !i.Paid;
var amounts = await db.Invoices
.Where(unpaid)
.Select(i => i.Amount)
.ToListAsync(ct);
That needs System.Linq.Expressions. EF receives the structure of the predicate: the parameter is an Invoice, Paid is a Boolean property, and the operation is negation. It can turn that structure into SQL. Microsoft’s expression tree explanation describes this exact relationship between the compiler and query providers.
The compiler checks the C# model and expression, and I test meaningful queries against the actual database provider to verify SQL translation and schema compatibility. EF’s translation rules guide how I write database expressions: provider-supported operations let the database do the filtering and aggregation efficiently.
In TypeScript, tools such as Drizzle provide typed schemas and SQL-oriented query builders. C#’s distinctive advantage here is that the compiler supplies the expression tree directly from a typed lambda. I can compose queries using the language’s own expressions and let EF inspect their structure. That integration feels especially natural as queries become more involved.
Change tracking saves repetitive update code
DbContext also acts as a unit of work. It keeps track of loaded entities and the changes made to them, then coordinates persistence through SaveChangesAsync.
var invoice = await db.Invoices.FindAsync(invoiceId);
if (invoice is null) return;
invoice.Paid = true;
await db.SaveChangesAsync(ct);
EF’s change tracker can identify which property changed and generate the update. If the tracked entity is unchanged, there is no entity update to send. With providers that support transactions, a normal SaveChanges operation can apply its changes atomically.
There’s a useful shortcut too: FindAsync returns an already tracked entity for that key, saving a database round trip. I use an ordinary LINQ query to query the database, and explicitly reload a tracked entity when I need to refresh it after another writer’s changes. Each operation has a clear purpose within the database session.
For read-only entity queries, AsNoTracking() avoids tracking overhead. EF also supports context pooling and compiled queries for measured hot paths. In Microsoft’s single-threaded benchmark fetching one row from a local SQL Server database, context pooling reduced mean time from 701.6 to 350.1 microseconds and allocations from 50.38 KB to 4.63 KB. Those are useful reductions in the overhead of that operation, with practical controls I can measure and apply as the product grows.
Dependency injection without the ceremony
Dependency injection sounds more complicated than it is. A service is just an object that does a job for the application. If it needs another object to do that job, we give it one. In .NET, the framework can create those objects and pass them to the right places for us.
The useful part is deciding which objects should be shared, and for how long. Suppose a customer asks for an invoice preview. As we prepare it, different parts of the application add notes for the response. Those notes belong to this one HTTP request. The next request, even from the same customer, should start with an empty list.
The wiring I’d otherwise have to manage
In a small TypeScript application using Express, I might wire that up by hand:
class RequestNotes {
messages: string[] = [];
}
class InvoicePreview {
constructor(private notes: RequestNotes) {}
check(amount: number) {
if (amount > 10_000) {
this.notes.messages.push("Approval required.");
}
}
}
app.get("/invoice-preview", (_req, res) => {
const notes = new RequestNotes();
const preview = new InvoicePreview(notes);
notes.messages.push("Preview requested.");
preview.check(15_000);
res.json(notes.messages);
});
Here, app is an Express application. The fixed amount keeps the example focused on sharing the notes; a real preview would use the invoice being requested.
This works. The route creates the notes, hands that same object to the preview service, then reads the messages it added. That’s already dependency injection, done manually.
The awkward part comes as more services need that same request data. I have to construct them with the right object, pass it through intermediate calls, or introduce a container or request-context mechanism. Moving notes outside the handler would share it across requests. Creating a fresh one inside each service would stop them sharing the same list within a request.
In a TypeScript stack, I would choose a mechanism such as NestJS’s request-scoped providers or Node’s AsyncLocalStorage to manage that sharing. ASP.NET Core gives me a standard request scope through the same container that creates my services. The registration expresses the lifetime, and the framework handles the wiring.
One registration gives each request its own data
Here’s the same pair of objects in C#:
public sealed class RequestNotes
{
public List<string> Messages { get; } = [];
}
public sealed class InvoicePreview(RequestNotes notes)
{
public void Check(decimal amount)
{
if (amount > 10_000m)
{
notes.Messages.Add("Approval required.");
}
}
}
InvoicePreview(RequestNotes notes) says that constructing a preview service requires a notes object. Now register both services when the web application starts:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<RequestNotes>();
builder.Services.AddScoped<InvoicePreview>();
var app = builder.Build();
app.MapGet("/invoice-preview", (
RequestNotes notes, InvoicePreview preview) =>
{
notes.Messages.Add("Preview requested.");
preview.Check(15_000m);
return TypedResults.Ok(notes.Messages);
});
app.Run();
AddScoped means one instance per HTTP request in this application. ASP.NET Core creates the preview service and gives it the very same RequestNotes object that it passes to the endpoint. Both messages appear in the response. Another request gets a new list, even if the two requests overlap.
Any other service in that request can declare RequestNotes as a constructor dependency and receive the same instance. I don’t have to teach the endpoint how to build every object further down the call chain. When the request finishes, its scope ends; the container also disposes services that need disposal. Microsoft’s service lifetime documentation describes that behaviour.
That’s the convenience I care about: I declare what a service needs and how long it should live, then get on with writing what it does.
The database uses the same mechanism
An invoice service can ask for a database context and a logger in exactly the same way:
public interface IInvoiceService
{
Task<int> CountUnpaidAsync(CancellationToken ct);
}
public sealed class InvoiceService : IInvoiceService
{
private readonly BillingDb _db;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(
BillingDb db, ILogger<InvoiceService> logger)
{
_db = db;
_logger = logger;
}
public Task<int> CountUnpaidAsync(CancellationToken ct)
{
_logger.LogDebug("Counting unpaid invoices");
return _db.Invoices.CountAsync(i => !i.Paid, ct);
}
}
Then register the implementation when the application starts:
builder.Services.AddDbContext<BillingDb>(options =>
options.UseNpgsql(
builder.Configuration.GetConnectionString("Billing")));
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
ASP.NET Core already supplies logging. Its DI container constructs the service and supplies its dependencies. Another class can ask for IInvoiceService in its constructor, and an endpoint can ask for it as a parameter. Testing is straightforward too: pass a test implementation directly.
AddDbContext is scoped by default too, so services in the request can work with the same tracked database session. For other lifetimes, AddSingleton shares an instance across the application, while AddTransient creates one each time it is requested from the container. A singleton shouldn’t hold onto a request-scoped service, and a database context isn’t safe for parallel operations.
What I value is that the web host, database, HTTP clients, logging and my own code all use the same conventions. Once you understand this example, you understand how a large part of a .NET application fits together.
Small functions make proper APIs
ASP.NET Core’s Minimal APIs let a function describe an HTTP endpoint. With the registrations above, this is a working route:
app.MapGet("/invoices/unpaid/count", async (
IInvoiceService invoices, CancellationToken ct) =>
TypedResults.Ok(await invoices.CountUnpaidAsync(ct)));
The framework injects the service and supplies cancellation when the request is aborted. TypedResults.Ok produces a typed HTTP 200 result. Route values, query parameters and request bodies can become typed function arguments too.
For the contract, add the matching .NET 10 version of Microsoft’s ASP.NET Core OpenAPI package. The OpenAPI setup fits around those registrations and endpoints:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
// Register BillingDb and IInvoiceService here.
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Map the invoice endpoint here.
app.Run();
The document at /openapi/v1.json describes the API’s operations and shapes. A client generator can turn that contract into TypeScript types and calls for the front end. Keeping generation in the build means a changed API can surface as a frontend type error instead of a surprise in the browser.
Minimal refers to the amount of ceremony. These data-access examples fit into the normal ASP.NET Core pipeline, where I configure authentication, authorisation, rate limiting and logging. To turn them into our SaaS endpoints, I’d add an authenticated organisation context, scope invoice queries to it and validate requests and permissions. The short endpoint definition sits within the same framework I’ll use to secure and operate the product.
Performance is a reason to choose it
The reputation for slowness is particularly out of date. Microsoft’s ASP.NET Core comparison presents these rounded results from TechEmpower Round 23’s small-JSON-response workload, accessed in September 2026:
| Implementation in Microsoft’s comparison | JSON responses per second |
|---|---|
| ASP.NET Core Minimal APIs | 2,050,000 |
| Java Servlet | 328,000 |
| Node.js | 224,000 |
For the implementations and workload shown, ASP.NET Core delivered roughly 9.2 times the Node.js result and 6.3 times the Java Servlet result. That is a strong demonstration of how efficiently it can handle HTTP requests and return JSON, a basic operation in the APIs I build.
I care about those numbers because they show substantial headroom in a framework that’s also convenient to use. Then I measure the application we’re building: database plans, allocations, response times under load and the cost of serving customers.
There are deployment choices too. In Microsoft’s 2023 .NET 8 lab example, publishing a small API with Native AOT reduced startup from 156 ms to 48 ms and working set from 96 MB to 41 MB. For applications whose dependencies support ahead-of-time compilation, that gives me another way to reduce startup time and memory use.
A good partner for AI-assisted development
Fast feedback matters much more when AI can write a feature in minutes. I want it to make a small change, compile it, run the relevant tests and fix what fails while the task is still fresh. C#’s quick compilation cycle suits that way of working, and incremental builds avoid repeating build steps whose inputs haven’t changed.
The compiler gives an agent specific things to fix: a method that doesn’t exist, an argument of the wrong type, a missing interface member or an unhandled union case. Those checks work across the application. If an agent changes a contract in one place and forgets its callers, the next build can show it where to look.
I make nullable-reference analysis and warnings part of that feedback by enabling them in the project:
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
There are useful differences from the other languages I use. Python’s type annotations aren’t enforced by its runtime; a separate type checker needs to be part of the workflow. TypeScript has a strong checker, including exhaustive checks for discriminated unions, but those types disappear from the JavaScript that runs. With C#, the compiler and runtime share typed contracts, and libraries such as EF can inspect typed expressions too.
That combination lets me move quickly with AI while keeping a high degree of confidence in the changes. A successful build tells me the code fits together. Tests tell me whether it does what we intended, and I review the decisions behind it. Those checks are a practical part of the development loop, so an agent can use them throughout the work.
Easy to test and benchmark
Business rules should be easy to test. Suppose we pull the overdue-invoice rule into a small function:
public static class InvoiceRules
{
public static bool IsOverdue(
Invoice invoice, DateTimeOffset now) =>
!invoice.Paid && invoice.DueAt < now;
}
Passing the time in makes the result predictable. With xUnit, I can cover the important cases in one parameterised test:
public sealed class InvoiceRulesTests
{
[Theory]
[InlineData(false, -1, true)]
[InlineData(true, -1, false)]
[InlineData(false, 0, false)]
[InlineData(false, 1, false)]
public void Identifies_overdue_invoices(
bool paid, int daysFromNow, bool expected)
{
var now = new DateTimeOffset(
2026, 9, 11, 12, 0, 0, TimeSpan.Zero);
var invoice = new Invoice
{
Paid = paid,
DueAt = now.AddDays(daysFromNow)
};
Assert.Equal(expected,
InvoiceRules.IsOverdue(invoice, now));
}
}
That test class belongs in an xUnit test project with using Xunit; and a reference to the application. Run it with dotnet test. The cases check an overdue invoice, a paid invoice, one due exactly now and one due tomorrow. An agent can run them after every change without opening a browser or setting up a customer account.
For the HTTP layer, ASP.NET Core’s integration-testing tools can host the application in a test process and send requests through its pipeline. I’d use those to check that a customer can read their own invoices and can’t read another organisation’s. Database tests use the actual provider so we’re checking the SQL behaviour too.
Performance is just as accessible. BenchmarkDotNet handles warm-up, repeated measurements and reporting. In a console project with its package installed, this measures serialising the request type from earlier:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text.Json;
BenchmarkRunner.Run<InvoiceJsonBenchmarks>();
[MemoryDiagnoser]
public class InvoiceJsonBenchmarks
{
private readonly CreateInvoice _request = new()
{
CustomerName = "Northstar Books",
Amount = 149.50m
};
[Benchmark]
public string Serialize() =>
JsonSerializer.Serialize(_request);
}
Run it with dotnet run -c Release to get timing and allocation measurements. I can compare an alternative implementation using another benchmark method, then see whether the change actually helped. This measures one operation; load tests tell me how the complete API behaves with concurrent customers.
For a production application, I want both kinds of evidence: tests that protect the behaviour customers rely on, and measurements that show where performance work is worthwhile. .NET makes it easy to keep both alongside the application code.
JSON and resilient API calls fit together
Calling another API shouldn’t require a collection of unrelated helpers. .NET’s HTTP JSON extensions let an injected HttpClient fetch a response and deserialize it into a real type:
public sealed record ExchangeRate(string Currency, decimal Rate);
var client = httpClientFactory.CreateClient("rates");
var rate = await client.GetFromJsonAsync<ExchangeRate>(
"latest?currency=CAD", ct);
That uses System.Net.Http.Json; httpClientFactory is an injected IHttpClientFactory. System.Text.Json is built in, with support for custom converters and source-generated serialisation when needed. I then validate the rate against the business rules before using it, with a typed object that makes the values straightforward to inspect and test.
For network failures, add Microsoft’s HTTP resilience package and configure the named client once:
builder.Services.AddHttpClient("rates", client =>
{
client.BaseAddress = new Uri("https://rates.example/");
}).AddStandardResilienceHandler();
The host is illustrative. The standard resilience handler provides timeouts, retries with exponential backoff and jitter, a circuit breaker and a concurrency limit. Jitter varies retry timing so callers don’t all hammer a recovering service together. A circuit breaker temporarily stops calls when failures cross its threshold.
This example performs a read. Retrying writes needs more thought: a timed-out payment request may already have succeeded. I’d configure retry rules for that API and use its idempotency mechanism where available. Central configuration makes that policy easier to review than retry loops scattered around the product.
Background work is an ordinary part of the ecosystem
A customer requests an export, uploads a document or triggers a batch of invoice emails. That work should survive beyond the HTTP request. A timer or an unawaited task doesn’t provide a durable job queue.
Hangfire is one established option. With its ASP.NET Core and SQL Server packages installed, this registers persistent storage and a worker:
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(
builder.Configuration.GetConnectionString("Jobs")));
builder.Services.AddHangfireServer();
This uses SQL Server for the queue’s storage, independently of the application’s PostgreSQL database. A PostgreSQL deployment can instead use a compatible community storage provider after checking its support and version requirements.
Once InvoiceExportJob is registered with DI and implements our export method, an injected IBackgroundJobClient can enqueue it:
jobs.Enqueue<InvoiceExportJob>(job =>
job.RunAsync(exportId));
Hangfire persists the method invocation and arguments, then a worker executes it. Expression trees show up again: the library can inspect the call without executing it in the request. Pass a durable export ID so the worker can load its own data.
Hangfire also supports delayed and recurring work, and automatically retries failed jobs. I make the export recognise completed work so retries are safe. Where recording and queuing the export must succeed together, I use a suitable transaction or outbox design to keep those steps coordinated. The queue supplies persistence and scheduling, leaving me to define the behaviour the business needs.
The wider attraction is the ecosystem. Configuration, logging, JSON and HTTP support already follow common conventions. Database providers, job processors and specialist packages extend them. Most business requirements start with choosing an established component and integrating it, rather than inventing the supporting infrastructure.
Commercial backing, without a platform licence
Microsoft has a commercial reason to keep .NET useful: developers build software that runs on its cloud and uses its tools. That gives the platform engineering investment and a published support lifecycle that I can plan around.
.NET first shipped in 2002, giving the platform more than two decades of development. Microsoft’s history of developer tools goes back much further. As of September 2026, .NET 10 is an LTS release supported until 14 November 2028, giving me a defined support window for maintenance and upgrade planning.
I value that continuity. The web host, dependency injection, configuration, logging and JSON support sit within one maintained platform, with common conventions and a published release lifecycle. That reduces the number of separate dependencies I need to assemble and maintain for the application’s foundations.
The compiler, runtime and core frameworks are free to use. You can run the backend in Linux containers, on your own servers or with a cloud provider of your choice. That leaves us free to choose hosting and any paid tools around the product’s needs and budget.
Why that matters when I’m building your product
My preference comes from the combination: expressive business logic, typed database queries, straightforward service wiring, good performance and an ecosystem built around running applications for years.
It’s also a choice I put into practice. Howdy uses C# and .NET for its application and delivery engines, with TypeScript for the interface. My other work includes Rust and Go; I choose around the product’s requirements. For the business applications described here, C# removes a lot of friction.
When we work together through Blueprint Ventures, I make those technical decisions and do the implementation, review and testing. You get the benefit of the stack without needing to become its expert. We can spend more of our conversations on what your customers need and how the product should work. How I work explains that arrangement.
If you’re planning a SaaS product, a dashboard or a business workflow that deserves better software, tell me about your project. A few paragraphs about the customer, the problem and what you want to achieve are enough to start.