ASP.NET Core 9: Building a Time-Based Order Processing Application with ASP.NET Core 9 and TickerQ
In this article, we will build a complete Order Processing Application using ASP.NET Core 9 with TickerQ — a modern, source-generated background job scheduler for .NET. The application demonstrates how to accept orders through a web UI, store them in SQL Server, and automatically process them on a timed schedule using TickerQ's cron-based execution engine.
What is TickerQ?
TickerQ is a high-performance, modern job scheduler for .NET applications. Unlike traditional approaches such as IHostedService with Timer, or Hangfire, TickerQ leverages source generators at compile time — meaning zero runtime reflection, full AOT compatibility, and maximum performance.
TickerQ supports two types of scheduled jobs:
- Time Tickers — One-off jobs scheduled to run at a specific future time.
- Cron Tickers — Recurring jobs defined using cron expressions (e.g.,
*/1 * * * *for every minute).
Key features of TickerQ include:
- Source-generated function registration : No magic strings or runtime discovery. The
[TickerFunction]attribute is processed at compile time. - EF Core persistence : Job state is persisted to your database (SQL Server, PostgreSQL, SQLite, MySQL) using Entity Framework Core.
- Built-in dashboard : Real-time SignalR-based monitoring UI.
- First-class DI support : Constructor injection works naturally in ticker function classes.
- Multi-node coordination : Distributed locking via Redis for scaled-out deployments.
- Retry & throttling : Configurable retry policies with backoff strategies.
Advantages of Using TickerQ
| Feature | TickerQ | Traditional Timer/IHostedService |
|---|---|---|
| Persistence | Built-in EF Core persistence | No persistence — jobs lost on restart |
| Scheduling | Cron expressions + one-off time-based | Manual Timer intervals only |
| Discovery | Source-generated at compile time | Manual registration required |
| AOT Ready | Yes — no reflection | Depends on implementation |
| Retry Logic | Built-in configurable retries | Must implement manually |
| Monitoring | Built-in SignalR dashboard | None |
| Multi-Node | Redis-based coordination | Not supported |
Application Overview
Our OrderProcessor application implements the following workflow:
- End users submit orders (single or batch) through Razor Views.
- Orders are saved to SQL Server with status "Pending".
- TickerQ runs a scheduled cron job that reads all pending orders.
- For each order, it checks: Is AdvancePaid >= 35% of TotalAmount?
- If yes → Approved. If no → Rejected.
- The updated status is saved to the database and reflected on the UI.
- Approved/Rejected orders are skipped in subsequent scans.
Architecture Diagram
- Step 1: User submits order(s) via Razor form in the browser.
- Step 2: MVC OrderController saves order to SQL Server with Status = "Pending".
- Step 3: TickerQ's CronTickerEntity triggers the [TickerFunction] on the configured schedule.
- Step 4: The TickerFunction calls OrderProcessingService.
- Step 5: Service reads all pending orders from the database.
- Step 6: Applies the 35% advance rule — Approve or Reject each order.
- Step 7: Updated statuses are reflected on the UI after automatic page reload.
Prerequisites
- .NET 9 SDK
- SQL Server (LocalDB or full instance)
- Visual Studio 2022 / VS Code
- EF Core CLI Tools (
dotnet tool install --global dotnet-ef)
Step-by-Step Implementation
Step 1: Create the Project and Install NuGet Packages
Open a terminal and run:
dotnet new webapi -n OrderProcessor --framework net9.0 --use-controllers cd OrderProcessor # Install EF Core for SQL Server dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.EntityFrameworkCore.Design # Install TickerQ and EF Core provider dotnet add package TickerQ --version 9.* dotnet add package TickerQ.EntityFrameworkCore --version 9.*
The project file (OrderProcessor.csproj) should look like this:
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.*" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*" /> <PackageReference Include="TickerQ" Version="9.*" /> <PackageReference Include="TickerQ.EntityFrameworkCore" Version="9.*" /> </ItemGroup> </Project>
Step 2: Create the Project Folder Structure
Create the following folders inside the project:
mkdir Models Data Services Tickers Controllers mkdir Views Views\Order Views\Shared mkdir wwwroot wwwroot\css
Step 3: Create the Order Model
File: Models/Order.cs
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace OrderProcessor.Models; public class Order { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int OrderId { get; set; } [Required] [MaxLength(100)] public string CustomerName { get; set; } = string.Empty; [Required] [MaxLength(200)] public string ItemName { get; set; } = string.Empty; [Range(1, int.MaxValue)] public int Quantity { get; set; } [Column(TypeName = "decimal(18,2)")] public decimal UnitPrice { get; set; } [Column(TypeName = "decimal(18,2)")] public decimal TotalAmount { get; set; } [Column(TypeName = "decimal(18,2)")] public decimal AdvancePaid { get; set; } [MaxLength(20)] public string OrderStatus { get; set; } = "Pending"; public DateTime OrderDate { get; set; } = DateTime.Now; }
Step 4: Create the Order DTO
File: Models/OrderDto.cs
using System.ComponentModel.DataAnnotations; namespace OrderProcessor.Models; public class OrderDto { [Required] [MaxLength(100)] public string CustomerName { get; set; } = string.Empty; [Required] [MaxLength(200)] public string ItemName { get; set; } = string.Empty; [Range(1, int.MaxValue)] public int Quantity { get; set; } [Range(0.01, double.MaxValue)] public decimal UnitPrice { get; set; } [Range(0, double.MaxValue)] public decimal AdvancePaid { get; set; } }
Step 5: Create the AppDbContext
File: Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore; using OrderProcessor.Models; namespace OrderProcessor.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Order> Orders { get; set; } }
Important Point: When using UseApplicationDbContext<AppDbContext> with TickerQ, the TickerQ tables (CronTickers, TimeTickers, CronTickerOccurrences) are automatically added to this same DbContext via the Model Customizer. This means a single dotnet ef database update creates both your application tables and TickerQ's operational store tables.
Step 6: Create the OrderProcessingService
This is the core business logic — shared between the TickerQ background ticker and the API endpoint.
File: Services/OrderProcessingService.cs
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using OrderProcessor.Data; namespace OrderProcessor.Services; public class OrderProcessingService { private readonly AppDbContext _context; private readonly ILogger<OrderProcessingService> _logger; public OrderProcessingService(AppDbContext context, ILogger<OrderProcessingService> logger) { _context = context; _logger = logger; } public async Task<ProcessingResult> ProcessPendingOrdersAsync( CancellationToken cancellationToken = default) { _logger.LogInformation("Order Processing: Starting scan at {Time}", DateTime.Now); var pendingOrders = await _context.Orders .Where(o => o.OrderStatus == "Pending") .ToListAsync(cancellationToken); if (pendingOrders.Count == 0) { _logger.LogInformation("Order Processing: No pending orders found."); return new ProcessingResult(); } int approved = 0, rejected = 0; foreach (var order in pendingOrders) { decimal requiredAdvance = order.TotalAmount * 0.35m; if (order.AdvancePaid >= requiredAdvance) { order.OrderStatus = "Approved"; approved++; _logger.LogInformation( "Order {OrderId} APPROVED - Advance: {Advance:C}, Required: {Required:C}", order.OrderId, order.AdvancePaid, requiredAdvance); } else { order.OrderStatus = "Rejected"; rejected++; _logger.LogInformation( "Order {OrderId} REJECTED - Advance: {Advance:C}, Required: {Required:C}", order.OrderId, order.AdvancePaid, requiredAdvance); } } await _context.SaveChangesAsync(cancellationToken); return new ProcessingResult { Approved = approved, Rejected = rejected }; } } public class ProcessingResult { public int Approved { get; set; } public int Rejected { get; set; } public int Total => Approved + Rejected; }
Step 7: Create the TickerQ Ticker Function
This is the class that TickerQ discovers at compile time via the [TickerFunction] attribute. It delegates to the shared OrderProcessingService.
File: Tickers/OrderProcessingTicker.cs
using Microsoft.Extensions.DependencyInjection; using OrderProcessor.Services; using TickerQ.Utilities.Base; namespace OrderProcessor.Tickers; public class OrderProcessingTicker { private readonly IServiceScopeFactory _scopeFactory; public OrderProcessingTicker(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } [TickerFunction("ProcessPendingOrders")] public async Task ProcessPendingOrders( TickerFunctionContext context, CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var service = scope.ServiceProvider .GetRequiredService<OrderProcessingService>(); await service.ProcessPendingOrdersAsync(cancellationToken); } }
- The
[TickerFunction("ProcessPendingOrders")]attribute registers this method with TickerQ's source generator. - The method receives a
TickerFunctionContext(job metadata) and aCancellationToken. - We use
IServiceScopeFactoryto create a new DI scope, because the DbContext is scoped.
Step 8: Create the API Controller (OrdersController)
File: Controllers/OrdersController.cs
using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OrderProcessor.Data; using OrderProcessor.Models; using OrderProcessor.Services; namespace OrderProcessor.Controllers; [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly AppDbContext _context; private readonly OrderProcessingService _processingService; public OrdersController(AppDbContext context, OrderProcessingService processingService) { _context = context; _processingService = processingService; } // POST: api/orders [HttpPost] public async Task<IActionResult> CreateOrder([FromBody] OrderDto dto) { var order = new Order { CustomerName = dto.CustomerName, ItemName = dto.ItemName, Quantity = dto.Quantity, UnitPrice = dto.UnitPrice, TotalAmount = dto.Quantity * dto.UnitPrice, AdvancePaid = dto.AdvancePaid, OrderStatus = "Pending", OrderDate = DateTime.Now }; _context.Orders.Add(order); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetOrder), new { id = order.OrderId }, order); } // POST: api/orders/batch [HttpPost("batch")] public async Task<IActionResult> CreateOrders([FromBody] List<OrderDto> dtos) { var orders = dtos.Select(dto => new Order { CustomerName = dto.CustomerName, ItemName = dto.ItemName, Quantity = dto.Quantity, UnitPrice = dto.UnitPrice, TotalAmount = dto.Quantity * dto.UnitPrice, AdvancePaid = dto.AdvancePaid, OrderStatus = "Pending", OrderDate = DateTime.Now }).ToList(); _context.Orders.AddRange(orders); await _context.SaveChangesAsync(); return Ok(new { Message = $"{orders.Count} orders created.", Orders = orders }); } // GET: api/orders [HttpGet] public async Task<IActionResult> GetOrders() { var orders = await _context.Orders .OrderByDescending(o => o.OrderDate).ToListAsync(); return Ok(orders); } // GET: api/orders/{id} [HttpGet("{id}")] public async Task<IActionResult> GetOrder(int id) { var order = await _context.Orders.FindAsync(id); if (order == null) return NotFound(new { Message = $"Order {id} not found." }); return Ok(order); } // GET: api/orders/pending [HttpGet("pending")] public async Task<IActionResult> GetPendingOrders() { var orders = await _context.Orders .Where(o => o.OrderStatus == "Pending") .OrderBy(o => o.OrderDate).ToListAsync(); return Ok(orders); } // POST: api/orders/process - Trigger processing from UI [HttpPost("process")] public async Task<IActionResult> ProcessOrders() { var result = await _processingService.ProcessPendingOrdersAsync(); return Ok(result); } }
Step 9: Create the MVC Controller (OrderController)
This controller serves the Razor Views for the web UI.
File: Controllers/OrderController.cs
using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OrderProcessor.Data; using OrderProcessor.Models; namespace OrderProcessor.Controllers; public class OrderController : Controller { private readonly AppDbContext _context; public OrderController(AppDbContext context) { _context = context; } public async Task<IActionResult> Index(string status = "All") { var query = _context.Orders.AsQueryable(); if (status != "All") query = query.Where(o => o.OrderStatus == status); var orders = await query.OrderByDescending(o => o.OrderDate).ToListAsync(); ViewBag.CurrentStatus = status; ViewBag.StatusCounts = new Dictionary<string, int> { ["All"] = await _context.Orders.CountAsync(), ["Pending"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Pending"), ["Approved"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Approved"), ["Rejected"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Rejected") }; return View(orders); } public IActionResult Create() => View(); [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Create(OrderDto dto) { if (!ModelState.IsValid) return View(dto); var order = new Order { CustomerName = dto.CustomerName, ItemName = dto.ItemName, Quantity = dto.Quantity, UnitPrice = dto.UnitPrice, TotalAmount = dto.Quantity * dto.UnitPrice, AdvancePaid = dto.AdvancePaid, OrderStatus = "Pending", OrderDate = DateTime.Now }; _context.Orders.Add(order); await _context.SaveChangesAsync(); TempData["Success"] = $"Order #{order.OrderId} created. Total: {order.TotalAmount:C}"; return RedirectToAction(nameof(Index)); } public IActionResult BatchCreate() => View(); [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> BatchCreate(List<OrderDto> orders) { if (orders == null || orders.Count == 0) { ModelState.AddModelError("", "Please add at least one order."); return View(); } var newOrders = orders.Select(dto => new Order { CustomerName = dto.CustomerName, ItemName = dto.ItemName, Quantity = dto.Quantity, UnitPrice = dto.UnitPrice, TotalAmount = dto.Quantity * dto.UnitPrice, AdvancePaid = dto.AdvancePaid, OrderStatus = "Pending", OrderDate = DateTime.Now }).ToList(); _context.Orders.AddRange(newOrders); await _context.SaveChangesAsync(); TempData["Success"] = $"{newOrders.Count} orders created successfully."; return RedirectToAction(nameof(Index)); } public async Task<IActionResult> Details(int id) { var order = await _context.Orders.FindAsync(id); if (order == null) return NotFound(); return View(order); } }
Step 10: Configure Program.cs
File: Program.cs — This is the application entry point where we wire up EF Core, TickerQ, and MVC.
using Microsoft.EntityFrameworkCore; using OrderProcessor.Data; using OrderProcessor.Services; using TickerQ.DependencyInjection; using TickerQ.EntityFrameworkCore.Customizer; using TickerQ.EntityFrameworkCore.DependencyInjection; using TickerQ.Utilities.Entities; using TickerQ.Utilities.Interfaces.Managers; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddOpenApi(); // EF Core with SQL Server builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("AppDb"))); // Register order processing service builder.Services.AddScoped<OrderProcessingService>(); // Register TickerQ with EF Core persistence using the application DbContext builder.Services.AddTickerQ(options => { options.AddOperationalStore(efOptions => { efOptions.UseApplicationDbContext<AppDbContext>( ConfigurationType.UseModelCustomizer); }); }); var app = builder.Build(); // Apply migrations on startup using (var scope = app.Services.CreateScope()) { var appDb = scope.ServiceProvider.GetRequiredService<AppDbContext>(); appDb.Database.Migrate(); // Schedule the cron ticker for order processing var cronManager = scope.ServiceProvider .GetRequiredService<ICronTickerManager<CronTickerEntity>>(); try { await cronManager.AddAsync(new CronTickerEntity { Function = "ProcessPendingOrders", Expression = "*/1 * * * *", IsEnabled = true }); Console.WriteLine("TickerQ: Cron ticker scheduled."); } catch { Console.WriteLine("TickerQ: Cron ticker already registered."); } } if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.UseTickerQ(); app.MapControllerRoute( name: "default", pattern: "{controller=Order}/{action=Index}/{id?}"); app.MapControllers(); app.Run();
Step 11: Configure appsettings.json
File: appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"AppDb": "Server=.;Database=OrderProcessorDb;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
Step 12: Create the Razor Views
12a. Views/_ViewImports.cshtml
@using OrderProcessor.Models @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
12b. Views/_ViewStart.cshtml
@{
Layout = "_Layout";
}
12c. Views/Shared/_Layout.cshtml
The shared layout provides Bootstrap 5 styling, navigation bar, and success alert rendering.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - OrderProcessor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet" />
<style>
.badge-pending { background-color: #ffc107; color: #000; }
.badge-approved { background-color: #198754; }
.badge-rejected { background-color: #dc3545; }
.stat-card { border-left: 4px solid; }
.stat-card.all { border-color: #0d6efd; }
.stat-card.pending { border-color: #ffc107; }
.stat-card.approved { border-color: #198754; }
.stat-card.rejected { border-color: #dc3545; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" asp-controller="Order" asp-action="Index">
<i class="bi bi-box-seam"></i> OrderProcessor
</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" asp-controller="Order" asp-action="Index">
<i class="bi bi-list-ul"></i> All Orders</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Order" asp-action="Create">
<i class="bi bi-plus-circle"></i> New Order</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-controller="Order" asp-action="BatchCreate">
<i class="bi bi-stack"></i> Batch Orders</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle-fill"></i> @TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@RenderBody()
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
12d. Views/Order/Index.cshtml — Order Dashboard with TickerQ Progress Bar
This is the main view. It shows status dashboard cards, a TickerQ processing progress bar with countdown timer, and the orders table. When the timer completes, it calls POST /api/orders/process to trigger server-side processing, then reloads the page.
@model List<Order>
@{
ViewData["Title"] = "Orders";
var statusCounts = ViewBag.StatusCounts as Dictionary<string, int>;
var currentStatus = ViewBag.CurrentStatus as string;
var hasPending = statusCounts != null && statusCounts["Pending"] > 0;
}
<!-- TickerQ Processing Progress Bar (visible only when pending orders exist) -->
@if (hasPending)
{
<div class="card border-warning mb-4 shadow-sm">
<div class="card-body py-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-gear-wide-connected fs-4 text-warning me-2 spinner-icon"></i>
<div>
<strong>TickerQ Order Processing</strong>
<span class="text-muted ms-2">
@statusCounts!["Pending"] pending order(s) awaiting processing
</span>
</div>
<div class="ms-auto text-end">
<small class="text-muted">Next scan in: </small>
<span class="badge bg-warning text-dark fs-6" id="countdown">--:--</span>
</div>
</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-warning"
id="progressBar" role="progressbar" style="width: 0%"></div>
</div>
<small class="text-muted mt-1 d-block">
TickerQ scans every 15 seconds.
Orders with Advance >= 35% of Total will be Approved, otherwise Rejected.
</small>
</div>
</div>
}
<!-- Status Dashboard Cards -->
<div class="row mb-4">
@foreach (var item in new[] {
("All", "collection", "text-primary", "all"),
("Pending", "clock-history", "text-warning", "pending"),
("Approved", "check-circle", "text-success", "approved"),
("Rejected", "x-circle", "text-danger", "rejected") })
{
<div class="col-md-3">
<div class="card stat-card @item.Item4 shadow-sm">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">@item.Item1</div>
<div class="fs-4 fw-bold">@statusCounts![item.Item1]</div>
</div>
<i class="bi bi-@item.Item2 fs-2 @item.Item3"></i>
</div>
</div>
</div>
</div>
}
</div>
<!-- Orders Table with Status Filter -->
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-table"></i> Orders</h5>
<div>
@foreach (var s in new[] { "All", "Pending", "Approved", "Rejected" })
{
<a asp-action="Index" asp-route-status="@s"
class="btn btn-sm @(currentStatus == s ? "btn-primary" : "btn-outline-secondary") me-1">
@s
</a>
}
</div>
</div>
<div class="card-body p-0">
@if (Model.Count == 0)
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">No orders found.</p>
<a asp-action="Create" class="btn btn-primary btn-sm">Create First Order</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>#</th><th>Customer</th><th>Item</th>
<th class="text-center">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end">Total</th>
<th class="text-end">Advance Paid</th>
<th class="text-center">Status</th>
<th>Date</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var order in Model)
{
var badgeClass = order.OrderStatus switch
{
"Approved" => "badge-approved",
"Rejected" => "badge-rejected",
_ => "badge-pending"
};
<tr data-order-id="@order.OrderId" data-status="@order.OrderStatus">
<td><strong>@order.OrderId</strong></td>
<td>@order.CustomerName</td>
<td>@order.ItemName</td>
<td class="text-center">@order.Quantity</td>
<td class="text-end">@order.UnitPrice.ToString("N2")</td>
<td class="text-end fw-bold">@order.TotalAmount.ToString("N2")</td>
<td class="text-end">@order.AdvancePaid.ToString("N2")</td>
<td class="text-center status-cell">
@if (order.OrderStatus == "Pending")
{
<span class="badge badge-pending">
<span class="spinner-border spinner-border-sm me-1"
style="width:0.7rem;height:0.7rem;"></span>
Processing...
</span>
}
else
{
<span class="badge @badgeClass">@order.OrderStatus</span>
}
</td>
<td><small>@order.OrderDate.ToString("dd-MMM-yyyy HH:mm")</small></td>
<td>
<a asp-action="Details" asp-route-id="@order.OrderId"
class="btn btn-sm btn-outline-info">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@section Scripts {
<style>
.spinner-icon { animation: spin 2s linear infinite; }
@@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<script>
(function () {
const CYCLE_SECONDS = 15;
let elapsed = 0;
let processing = false;
const progressBar = document.getElementById('progressBar');
const countdown = document.getElementById('countdown');
const hasPending = document.querySelectorAll('tr[data-status="Pending"]').length > 0;
if (!hasPending || !progressBar || !countdown) return;
const timer = setInterval(function () {
elapsed++;
if (elapsed >= CYCLE_SECONDS) {
clearInterval(timer);
if (!processing) {
processing = true;
progressBar.style.width = '100%';
progressBar.classList.remove('bg-warning');
progressBar.classList.add('bg-info');
progressBar.classList.remove('progress-bar-animated');
countdown.textContent = 'Processing...';
// Call server to process pending orders
fetch('/api/orders/process', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (result) {
var total = (result.approved || 0) + (result.rejected || 0);
progressBar.classList.remove('bg-info');
progressBar.classList.add('bg-success');
countdown.textContent = total > 0
? (total + ' order(s) processed!') : 'No pending orders';
setTimeout(function () { location.reload(); }, 1500);
})
.catch(function () {
countdown.textContent = 'Retrying...';
setTimeout(function () { location.reload(); }, 1500);
});
}
return;
}
var pct = (elapsed / CYCLE_SECONDS) * 100;
progressBar.style.width = pct + '%';
var remaining = CYCLE_SECONDS - elapsed;
var mins = Math.floor(remaining / 60);
var secs = remaining % 60;
countdown.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
}, 1000);
})();
</script>
}
12e. Views/Order/Create.cshtml — Single Order Form
The create form shows a live calculation preview of Total Amount and Advance Paid as the user types.
@model OrderDto
@{ ViewData["Title"] = "Create Order"; }
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-plus-circle"></i> Create New Order</h5>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
<div class="mb-3">
<label asp-for="CustomerName" class="form-label">Customer Name</label>
<input asp-for="CustomerName" class="form-control" />
<span asp-validation-for="CustomerName" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="ItemName" class="form-label">Item Name</label>
<input asp-for="ItemName" class="form-control" />
<span asp-validation-for="ItemName" class="text-danger"></span>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="Quantity" class="form-label">Quantity</label>
<input asp-for="Quantity" class="form-control" type="number" min="1" value="1" />
</div>
<div class="col-md-6 mb-3">
<label asp-for="UnitPrice" class="form-label">Unit Price</label>
<input asp-for="UnitPrice" class="form-control" type="number" step="0.01" />
</div>
</div>
<div class="mb-3">
<label asp-for="AdvancePaid" class="form-label">Advance Paid</label>
<input asp-for="AdvancePaid" class="form-control" type="number" step="0.01" />
</div>
<div id="calcPreview" class="alert alert-info d-none mb-3">
<div class="row text-center">
<div class="col-6">
<small class="text-muted">Total Amount</small>
<div class="fw-bold" id="totalAmount">--</div>
</div>
<div class="col-6">
<small class="text-muted">Advance Paid</small>
<div class="fw-bold" id="advanceDisplay">--</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Submit Order</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.addEventListener('input', function () {
const qty = parseFloat(document.querySelector('[name="Quantity"]').value) || 0;
const price = parseFloat(document.querySelector('[name="UnitPrice"]').value) || 0;
const advance = parseFloat(document.querySelector('[name="AdvancePaid"]').value) || 0;
const total = qty * price;
const preview = document.getElementById('calcPreview');
if (total > 0) {
preview.classList.remove('d-none');
document.getElementById('totalAmount').textContent = total.toFixed(2);
document.getElementById('advanceDisplay').textContent = advance.toFixed(2);
} else { preview.classList.add('d-none'); }
});
</script>
}
12f. Views/Order/BatchCreate.cshtml — Batch Order Form
Users can dynamically add/remove order rows and submit them all at once.
@{ ViewData["Title"] = "Batch Create Orders"; }
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between">
<h5 class="mb-0"><i class="bi bi-stack"></i> Batch Create Orders</h5>
<button type="button" class="btn btn-sm btn-success" id="addRow">
<i class="bi bi-plus-lg"></i> Add Order Row</button>
</div>
<div class="card-body">
<form asp-action="BatchCreate" method="post">
<table class="table align-middle" id="ordersTable">
<thead class="table-light">
<tr>
<th>#</th><th>Customer</th><th>Item</th>
<th>Qty</th><th>Unit Price</th>
<th>Advance</th><th>Total</th><th></th>
</tr>
</thead>
<tbody id="orderRows"></tbody>
</table>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
<i class="bi bi-check-lg"></i> Submit All Orders</button>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
let rowIndex = 0;
function addRow() {
const i = rowIndex++;
const tr = document.createElement('tr');
tr.id = 'row-' + i;
tr.innerHTML = `
<td class="fw-bold">${i+1}</td>
<td><input name="orders[${i}].CustomerName" class="form-control form-control-sm"
required placeholder="Customer" /></td>
<td><input name="orders[${i}].ItemName" class="form-control form-control-sm"
required placeholder="Item" /></td>
<td><input name="orders[${i}].Quantity" type="number" min="1" value="1"
class="form-control form-control-sm qty" data-row="${i}"
required style="width:70px" /></td>
<td><input name="orders[${i}].UnitPrice" type="number" step="0.01" min="0.01"
class="form-control form-control-sm price" data-row="${i}"
required style="width:110px" /></td>
<td><input name="orders[${i}].AdvancePaid" type="number" step="0.01" min="0"
value="0" class="form-control form-control-sm advance" data-row="${i}"
required style="width:110px" /></td>
<td class="fw-bold" id="total-${i}">0.00</td>
<td><button type="button" class="btn btn-sm btn-outline-danger remove-row"
data-row="${i}"><i class="bi bi-trash"></i></button></td>`;
document.getElementById('orderRows').appendChild(tr);
document.getElementById('submitBtn').disabled = false;
}
document.getElementById('addRow').addEventListener('click', addRow);
document.getElementById('ordersTable').addEventListener('input', function(e) {
const r = e.target.dataset.row;
if (r === undefined) return;
const qty = parseFloat(document.querySelector('.qty[data-row="'+r+'"]')?.value)||0;
const price = parseFloat(document.querySelector('.price[data-row="'+r+'"]')?.value)||0;
document.getElementById('total-'+r).textContent = (qty*price).toFixed(2);
});
document.getElementById('ordersTable').addEventListener('click', function(e) {
const btn = e.target.closest('.remove-row');
if (btn) {
document.getElementById('row-'+btn.dataset.row)?.remove();
document.getElementById('submitBtn').disabled =
document.querySelectorAll('#orderRows tr').length === 0;
}
});
addRow(); // Start with one row
</script>
}
12g. Views/Order/Details.cshtml — Order Details View
Shows full order details with the 35% advance check result.
@model Order
@{
ViewData["Title"] = $"Order #{Model.OrderId}";
var required = Model.TotalAmount * 0.35m;
var badgeClass = Model.OrderStatus switch
{
"Approved" => "badge-approved",
"Rejected" => "badge-rejected",
_ => "badge-pending"
};
var meetsRequirement = Model.AdvancePaid >= required;
}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between">
<h5 class="mb-0"><i class="bi bi-receipt"></i> Order #@Model.OrderId</h5>
<span class="badge @badgeClass fs-6">@Model.OrderStatus</span>
</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr><th class="text-muted">Customer</th><td>@Model.CustomerName</td></tr>
<tr><th class="text-muted">Item</th><td>@Model.ItemName</td></tr>
<tr><th class="text-muted">Quantity</th><td>@Model.Quantity</td></tr>
<tr><th class="text-muted">Unit Price</th><td>@Model.UnitPrice.ToString("N2")</td></tr>
<tr><th class="text-muted">Total Amount</th>
<td class="fw-bold fs-5">@Model.TotalAmount.ToString("N2")</td></tr>
<tr><th class="text-muted">Advance Paid</th>
<td class="@(meetsRequirement ? "text-success" : "text-danger") fw-bold">
@Model.AdvancePaid.ToString("N2")</td></tr>
<tr><th class="text-muted">35% Required</th>
<td>@required.ToString("N2")</td></tr>
<tr><th class="text-muted">Advance Check</th>
<td>
@if (meetsRequirement)
{ <span class="text-success"><i class="bi bi-check-circle-fill"></i>
Advance >= 35% - Meets requirement</span> }
else
{ <span class="text-danger"><i class="bi bi-x-circle-fill"></i>
Advance < 35% - Does not meet requirement</span> }
</td></tr>
<tr><th class="text-muted">Order Date</th>
<td>@Model.OrderDate.ToString("dd-MMM-yyyy HH:mm:ss")</td></tr>
</table>
</div>
<div class="card-footer bg-white">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Orders</a>
</div>
</div>
</div>
</div>
Step 13: Create Migration and Update Database
# Create migration (includes both Orders table and TickerQ tables) dotnet ef migrations add InitialCreate --context AppDbContext # Apply migration to SQL Server dotnet ef database update --context AppDbContext
This single migration creates:
dbo.Orders— Your application order dataticker.CronTickers— TickerQ cron job definitionsticker.TimeTickers— TickerQ time-based jobsticker.CronTickerOccurrences— TickerQ cron execution history
Step 14: Run the Application
dotnet run
The browser will open automatically at http://localhost:5025. You will see the Order Dashboard.
How It Works — End to End
- Create Orders: Use the "New Order" or "Batch Orders" navigation to submit orders. Each order is saved with
Status = Pending. - Progress Bar Appears: On the Index page, a TickerQ progress bar starts a 15-second countdown with an animated striped bar.
- Timer Completes: When the countdown reaches 0, the UI calls
POST /api/orders/process. - Server Processes: The
OrderProcessingServicereads all Pending orders and applies the 35% advance rule:- If
AdvancePaid >= 35% of TotalAmount→ Status changed to Approved - If
AdvancePaid < 35% of TotalAmount→ Status changed to Rejected
- If
- UI Updates: The progress bar turns green, shows "X order(s) processed!", and the page reloads to show updated statuses.
- Subsequent Scans: Approved and Rejected orders are skipped — only Pending orders are processed.
Example
| Order | Total | 35% Required | Advance Paid | Result |
|---|---|---|---|---|
| Laptop (1 x 500,000) | 500,000.00 | 175,000.00 | 200,000.00 | Approved |
| Bulb (200 x 500) | 100,000.00 | 35,000.00 | 500.00 | Rejected |
After Running the Application, the Result will be shown as explain in the Video.
Conclusion
In this article, we built a complete Order Processing Application using ASP.NET Core 9 with TickerQ. We demonstrated:
- How to integrate TickerQ with Entity Framework Core and SQL Server for persistent job scheduling.
- How to use the
[TickerFunction]attribute for source-generated function discovery. - How to register CronTickerEntity for recurring scheduled execution.
- How to build a responsive Razor Views UI with Bootstrap 5, including a real-time progress bar that triggers server-side processing.
- How to apply business rules (35% advance validation) in a shared service consumed by both the background ticker and the API.
TickerQ provides a modern, compile-time-safe, and persistence-backed alternative to traditional background job approaches in .NET making it an excellent choice for applications that need reliable scheduled task execution.
