
Introduction
As an ASP.NET developer, you’re likely accustomed to building robust backend systems using C#, SQL Server, and frameworks like ASP.NET MVC or WebForms. However, modern web development increasingly demands dynamic, client-side experiences, which is where Angular shines. In this comprehensive guide, we’ll build an Invoice Entry System, a real-world enterprise application, using Angular for the front-end, ASP.NET Core for the backend, SQL Server with stored procedures, Dapper for data access, and CQRS (Command Query Responsibility Segregation) for a clean architecture.
We’ll integrate Serilog for logging, implement error handling and database transactions, and compare this modern stack with traditional ASP.NET MVC, WebForms, and jQuery/AJAX approaches.This blog post is tailored for ASP.NET developers transitioning to full-stack development with Angular.
We’ll cover
- Detailed Implementation: Step-by-step code for the Invoice Entry System.
- Real-World Scenarios: Use cases in retail, finance, and ERP systems.
- Pros and Cons: Comparing Angular + ASP.NET Core with MVC, WebForms, and jQuery.
- Best Practices: Architecture, error handling, transactions, and logging.
- Logging with Serilog: Capturing application events and errors.
- Code Examples and Outputs: Practical, working code with expected results.
1. Project Overview
Invoice Entry System
The Invoice Entry System is a full-stack application that allows users to:
- Create Invoices: Enter customer details and multiple items (product, quantity, price) with dynamic total calculations.
- View Invoices: Display a list of invoices with their items and totals.
- Validate Inputs: Ensure required fields and valid data (e.g., positive quantities).
- Log Operations: Track API calls, errors, and user actions using Serilog.
- Use Transactions: Ensure atomicity when saving invoices and items.
- Dynamic API URL: Configure the API endpoint for development and production.
- Bootstrap Styling: Provide a responsive, polished UI.
Real-World Scenarios
This system mirrors common enterprise applications:
- Retail: Creating invoices for customer purchases (e.g., at a supermarket).
- Finance: Managing invoices for billing clients in accounting systems.
- ERP Systems: Integrating with modules like SAP or Dynamics for inventory and sales.
- E-Commerce: Generating invoices for online orders.
Technology Stack
- Front-End: Angular with TypeScript, Bootstrap for styling.
- Back-End: ASP.NET Core Web API, Dapper for data access, CQRS for architecture.
- Database: SQL Server with stored procedures.
- Logging: Serilog for structured logging.
- Error Handling: Centralized in API and Angular.
- Transactions: Managed in stored procedures and Dapper.
2. Why Angular + ASP.NET Core?
As an ASP.NET developer, you’re familiar with C#, MVC, and structured development. Angular complements this with:
- TypeScript: Similar to C# with static typing and classes.
- Component-Based Architecture: Like MVC’s Views but client-side.
- Dependency Injection (DI): Mirrors ASP.NET Core’s DI system.
- Seamless API Integration: Angular’s HttpClient works well with ASP.NET Core APIs.
- Enterprise-Ready: Angular’s modularity suits complex systems like those in .NET ecosystems.
Comparison with MVC, WebForms, and jQuery
|
Aspect |
Angular + ASP.NET Core |
ASP.NET MVC |
WebForms |
jQuery/AJAX |
|---|---|---|---|---|
|
Architecture |
Component-based (Angular) + CQRS (API); separates client/server logic. |
MVC pattern; server-side rendering with controllers and views. |
Code-behind model; tightly coupled UI and logic. |
No architecture; manual DOM manipulation. |
|
Data Binding |
Two-way binding in Angular; declarative UI updates. |
Server-side model binding; Razor views. |
Server-side controls; limited client-side dynamism. |
Manual DOM updates via JavaScript. |
|
Scalability |
CQRS enables separate read/write scaling; Dapper is lightweight. |
Scales well with EF Core but heavier than Dapper. |
Poor scalability due to stateful nature. |
Scales poorly for complex apps. |
|
Maintainability |
Modular with components, services, and CQRS; TypeScript ensures type safety. |
Modular with controllers and services; less client-side structure. |
Hard to maintain due to code-behind. |
Spaghetti code in large apps. |
|
Performance |
Angular’s SPA with lazy loading; Dapper’s minimal overhead. |
Server-side rendering can be slower for dynamic UIs. |
Heavy server-side processing; ViewState bloats pages. |
Fast for small scripts but inefficient for SPAs. |
|
Learning Curve |
Steep for Angular (TypeScript, RxJS); familiar for .NET developers. |
Moderate; familiar to .NET developers. |
Easier but outdated; limited modern use. |
Easy for small tasks; complex for large apps. |
Real-World Analogy
Building with Angular + ASP.NET Core is like constructing a modular house with a factory (components, services) and a robust foundation (CQRS, Dapper). MVC is like a custom-built house with blueprints (Razor views), WebForms is like an old, rigid structure, and jQuery is like assembling furniture manually without a plan.
3. Setting Up the Environment Prerequisites
- SQL Server: Install SQL Server Express or Developer Edition.
- Visual Studio: For ASP.NET Core development.
- Node.js: LTS version (e.g., 18.x) from nodejs.org.
- Angular CLI: npm install -g @angular/cli.
- NuGet Packages: Dapper, Serilog, MediatR.
- Bootstrap: For Angular UI styling.
Step 1. Set Up SQL Server
- Create a database
CREATE DATABASE InvoiceDb; - Create tables
USE InvoiceDb; CREATE TABLE Invoices ( Id INT PRIMARY KEY IDENTITY(1,1), CustomerName NVARCHAR(100) NOT NULL, InvoiceDate DATE NOT NULL ); CREATE TABLE InvoiceItems ( Id INT PRIMARY KEY IDENTITY(1,1), InvoiceId INT NOT NULL, ProductName NVARCHAR(100) NOT NULL, Quantity INT NOT NULL, UnitPrice DECIMAL(18,2) NOT NULL, FOREIGN KEY (InvoiceId) REFERENCES Invoices(Id) ); - Create stored procedures
- Create Invoice
CREATE PROCEDURE sp_CreateInvoice @CustomerName NVARCHAR(100), @InvoiceDate DATE, @ItemsXml XML, @NewInvoiceId INT OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- Insert Invoice INSERT INTO Invoices (CustomerName, InvoiceDate) VALUES (@CustomerName, @InvoiceDate); SET @NewInvoiceId = SCOPE_IDENTITY(); -- Insert Items from XML INSERT INTO InvoiceItems (InvoiceId, ProductName, Quantity, UnitPrice) SELECT @NewInvoiceId, Item.value('(ProductName/text())[1]', 'NVARCHAR(100)'), Item.value('(Quantity/text())[1]', 'INT'), Item.value('(UnitPrice/text())[1]', 'DECIMAL(18,2)') FROM @ItemsXml.nodes('/Items/Item') AS T(Item); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE(); DECLARE @ErrorSeverity INT = ERROR_SEVERITY(); DECLARE @ErrorState INT = ERROR_STATE(); RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState); END CATCH END; - Get Invoices
CREATE PROCEDURE sp_GetInvoices AS BEGIN SET NOCOUNT ON; SELECT i.Id, i.CustomerName, i.InvoiceDate, ( SELECT it.Id, it.ProductName, it.Quantity, it.UnitPrice, (it.Quantity * it.UnitPrice) AS Total FROM InvoiceItems it WHERE it.InvoiceId = i.Id FOR JSON PATH ) AS Items FROM Invoices i FOR JSON PATH; END;
- Create Invoice
Step 2. Set Up ASP.NET Core API
- Create a new ASP.NET Core Web API project named InvoiceApi.
- Install NuGet packages:
dotnet add package Dapper dotnet add package System.Data.SqlClient dotnet add package MediatR dotnet add package MediatR.Extensions.Microsoft.DependencyInjection dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.File - Configure appsettings.json:
{ "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=InvoiceDb;Trusted_Connection=True;" }, "Serilog": { "Using": [ "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } }, "WriteTo": [ { "Name": "File", "Args": { "path": "Logs/log-.txt", "rollingInterval": "Day", "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}" } } ] } } - Set up Serilog in Program.cs:
using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Serilog; using MediatR; var builder = WebApplication.CreateBuilder(args); // Configure Serilog builder.Host.UseSerilog((context, configuration) => { configuration.ReadFrom.Configuration(context.Configuration); }); builder.Services.AddControllers(); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.AddCors(options => { options.AddPolicy("AllowAll", builder => { builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); }); }); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseCors("AllowAll"); app.UseAuthorization(); app.MapControllers(); app.Run();
Step 3. Implement CQRS with Dapper
- Create models in Models/Invoice.cs:
public class Invoice { public int Id { get; set; } public string CustomerName { get; set; } public DateTime InvoiceDate { get; set; } public List<InvoiceItem> Items { get; set; } public decimal TotalAmount => Items?.Sum(item => item.Total) ?? 0; } public class InvoiceItem { public int Id { get; set; } public string ProductName { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal Total => Quantity * UnitPrice; } - Create a command in Commands/CreateInvoiceCommand.cs:
using MediatR; public class CreateInvoiceCommand : IRequest<int> { public string CustomerName { get; set; } public DateTime InvoiceDate { get; set; } public List<InvoiceItemDto> Items { get; set; } } public class InvoiceItemDto { public string ProductName { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } } - Create a query in Queries/GetInvoicesQuery.cs:
using MediatR; using System.Collections.Generic; public class GetInvoicesQuery : IRequest<List<Invoice>> { } - Create a command handler in Handlers/CreateInvoiceCommandHandler.cs:
using Dapper; using MediatR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Data.SqlClient; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; public class CreateInvoiceCommandHandler : IRequestHandler<CreateInvoiceCommand, int> { private readonly string _connectionString; private readonly ILogger<CreateInvoiceCommandHandler> _logger; public CreateInvoiceCommandHandler(IConfiguration configuration, ILogger<CreateInvoiceCommandHandler> logger) { _connectionString = configuration.GetConnectionString("DefaultConnection"); _logger = logger; } public async Task<int> Handle(CreateInvoiceCommand request, CancellationToken cancellationToken) { _logger.LogInformation("Creating invoice for customer: {CustomerName}", request.CustomerName); try { // Validate inputs if (string.IsNullOrWhiteSpace(request.CustomerName)) { _logger.LogWarning("Invalid customer name provided."); throw new ArgumentException("Customer name is required."); } if (!request.Items.Any()) { _logger.LogWarning("No items provided for invoice."); throw new ArgumentException("At least one item is required."); } // Create XML for items var itemsXml = new XElement("Items", request.Items.Select(item => new XElement("Item", new XElement("ProductName", item.ProductName), new XElement("Quantity", item.Quantity), new XElement("UnitPrice", item.UnitPrice)))); using (var connection = new SqlConnection(_connectionString)) { await connection.OpenAsync(cancellationToken); var newInvoiceId = await connection.ExecuteScalarAsync<int>( "sp_CreateInvoice", new { CustomerName = request.CustomerName, InvoiceDate = request.InvoiceDate, ItemsXml = itemsXml.ToString(), NewInvoiceId = 0 }, commandType: System.Data.CommandType.StoredProcedure); _logger.LogInformation("Invoice created successfully with ID: {InvoiceId}", newInvoiceId); return newInvoiceId; } } catch (SqlException ex) { _logger.LogError(ex, "Database error while creating invoice for customer: {CustomerName}", request.CustomerName); throw new Exception($"Database error: {ex.Message}", ex); } catch (Exception ex) { _logger.LogError(ex, "Error creating invoice for customer: {CustomerName}", request.CustomerName); throw new Exception($"Error creating invoice: {ex.Message}", ex); } } } - Create a query handler in Handlers/GetInvoicesQueryHandler.cs:
using Dapper; using MediatR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Data.SqlClient; using System.Text.Json; using System.Threading; using System.Threading.Tasks; public class GetInvoicesQueryHandler : IRequestHandler<GetInvoicesQuery, List<Invoice>> { private readonly string _connectionString; private readonly ILogger<GetInvoicesQueryHandler> _logger; public GetInvoicesQueryHandler(IConfiguration configuration, ILogger<GetInvoicesQueryHandler> logger) { _connectionString = configuration.GetConnectionString("DefaultConnection"); _logger = logger; } public async Task<List<Invoice>> Handle(GetInvoicesQuery request, CancellationToken cancellationToken) { _logger.LogInformation("Retrieving all invoices."); try { using (var connection = new SqlConnection(_connectionString)) { var jsonResult = await connection.QuerySingleAsync<string>( "sp_GetInvoices", commandType: System.Data.CommandType.StoredProcedure); var invoices = JsonSerializer.Deserialize<List<Invoice>>(jsonResult); _logger.LogInformation("Retrieved {Count} invoices.", invoices.Count); return invoices; } } catch (SqlException ex) { _logger.LogError(ex, "Database error while retrieving invoices."); throw new Exception($"Database error: {ex.Message}", ex); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving invoices."); throw new Exception($"Error retrieving invoices: {ex.Message}", ex); } } } - Update the controller in Controllers/InvoicesController.cs:
using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System.Threading.Tasks; [Route("api/[controller]")] [ApiController] public class InvoicesController : ControllerBase { private readonly IMediator _mediator; private readonly ILogger<InvoicesController> _logger; public InvoicesController(IMediator mediator, ILogger<InvoicesController> logger) { _mediator = mediator; _logger = logger; } [HttpGet] public async Task<IActionResult> GetInvoices() { _logger.LogInformation("API call: GetInvoices"); try { var invoices = await _mediator.Send(new GetInvoicesQuery()); return Ok(invoices); } catch (Exception ex) { _logger.LogError(ex, "Error in GetInvoices API call."); return StatusCode(500, new { Message = ex.Message }); } } [HttpPost] public async Task<IActionResult> CreateInvoice([FromBody] CreateInvoiceCommand command) { _logger.LogInformation("API call: CreateInvoice for customer: {CustomerName}", command.CustomerName); try { var newInvoiceId = await _mediator.Send(command); return Ok(new { Id = newInvoiceId }); } catch (Exception ex) { _logger.LogError(ex, "Error in CreateInvoice API call for customer: {CustomerName}", command.CustomerName); return BadRequest(new { Message = ex.Message }); } } }

Join the conversation! Your thoughts help the community grow.