Entity Framework Core is fantastic for rapid development. But sometimes you need more control:
- Complex queries that don't translate well to LINQ
- Performance-critical paths where every millisecond counts
- Legacy SQL that you can't easily refactor
- Database-specific features that ORMs don't expose
That's where ADO.NET shines. You write the SQL, you control the execution, you optimize the queries. And with WitDatabase's ADO.NET provider, you get all of this with an embedded database — no server required.
In this tutorial, we'll build a complete REST API with:
- Users API — CRUD operations, search, pagination
- Products API — CRUD, bulk import, statistics, filtering
- Swagger UI — Interactive API documentation
- Transaction support — For bulk operations
By the end, you'll understand when to choose ADO.NET over EF Core and how to structure a clean, maintainable data access layer.
[[Svg Src="./witdatabase-swagger-endpoints.svg" Alt="witdatabase-swagger-endpoints"]]
Let's build it!
Prerequisites & Setup
Requirements
- .NET 9.0 or .NET 10.0 SDK
- Any operating system: Windows, Linux, or macOS
- A REST client: Swagger UI (built-in), Postman, or curl
Running the Sample
# Clone the repository
git clone https://github.com/dmitrat/WitDatabase.git
# Navigate to the sample
cd WitDatabase/Samples/OutWit.Database.Samples.WebApi
# Run the application
dotnet run
Access the API
Once running, open your browser:
| Resource | URL |
|---|---|
| Swagger UI | http://localhost:5184 |
| API Base | http://localhost:5184/api |
The Swagger UI provides an interactive interface to test all endpoints without writing any code.
💡 Tip: The database file (
webapi_sample.witdb) is created automatically on first run with seed data.
Project Architecture
Folder Structure
[[Svg Src="./witdatabase-webapi-project-structure.svg" Alt="witdatabase-webapi-project-structure"]]
Architecture Overview
[[Svg Src="./witdatabase-webapi-request-flow.svg" Alt="witdatabase-webapi-request-flow"]]
ADO.NET vs EF Core: When to Choose
| Aspect | ADO.NET (This Sample) | EF Core |
|---|---|---|
| SQL Control | Full manual control | LINQ-generated |
| Performance | Lower overhead | Higher abstraction |
| Code Volume | More code | Less code |
| Flexibility | Maximum | Framework constraints |
| Learning Curve | SQL knowledge required | LINQ abstractions |
| Best For | Performance-critical, complex queries | Rapid development, CRUD apps |
Choose ADO.NET when:
- You need precise control over SQL
- Performance is critical
- You have complex queries that don't map well to LINQ
- You're migrating existing raw SQL code
Choose EF Core when:
- Rapid development is the priority
- You want automatic change tracking
- You need migrations
- Your queries are straightforward CRUD
Connection Setup
The foundation of our API is a properly configured database connection. Let's look at Program.cs:
Connection String Configuration
var builder = WebApplication.CreateBuilder(args);
// Get connection string from configuration (with fallback)
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=webapi_sample.witdb";
In appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=webapi_sample.witdb"
}
}
Singleton Connection Pattern
For an embedded database, we use a singleton connection that lives for the application's lifetime:
// Create and open the shared connection
var sharedConnection = new WitDbConnection(connectionString);
sharedConnection.Open();
// Register as singleton - will be disposed when app shuts down
builder.Services.AddSingleton(sharedConnection);
// Register services that will use this connection
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<ProductService>();
Why Singleton? Unlike SQL Server where you'd use connection pooling, embedded databases like WitDatabase work best with a single connection that's reused. This avoids file locking issues and maximizes performance.
Graceful Shutdown
Proper cleanup is essential to prevent data loss:
try
{
await app.RunAsync();
}
finally
{
// Ensure data is flushed and connection is properly closed
Console.WriteLine("Shutting down - flushing database...");
sharedConnection.Engine?.Flush();
sharedConnection.Close();
sharedConnection.Dispose();
Console.WriteLine("Database closed.");
}
The Flush() call ensures all cached writes are persisted to disk before shutdown.
Complete Program.cs
using OutWit.Database.AdoNet;
using OutWit.Database.Samples.WebApi.Services;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=webapi_sample.witdb";
var sharedConnection = new WitDbConnection(connectionString);
sharedConnection.Open();
builder.Services.AddSingleton(sharedConnection);
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<ProductService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "WitDatabase WebAPI Sample v1");
c.RoutePrefix = string.Empty; // Swagger at root URL
});
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
// Initialize database
using (var scope = app.Services.CreateScope())
{
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync();
}
try
{
await app.RunAsync();
}
finally
{
sharedConnection.Engine?.Flush();
sharedConnection.Close();
sharedConnection.Dispose();
}
Database Initialization
The DatabaseInitializer service creates tables and seeds initial data on application startup.
Schema Creation
public class DatabaseInitializer
{
private readonly WitDbConnection _connection;
public DatabaseInitializer(WitDbConnection connection)
{
_connection = connection;
}
public async Task InitializeAsync()
{
await CreateTablesAsync();
await SeedDataAsync();
}
private async Task CreateTablesAsync()
{
using var command = _connection.CreateCommand();
// Users table
command.CommandText = """
CREATE TABLE IF NOT EXISTS Users (
Id BIGINT PRIMARY KEY AUTOINCREMENT,
Name VARCHAR(100) NOT NULL,
Email VARCHAR(255) NOT NULL,
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
""";
await command.ExecuteNonQueryAsync();
// Unique index on email
command.CommandText = """
CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Email
ON Users(Email)
""";
await command.ExecuteNonQueryAsync();
// Products table
command.CommandText = """
CREATE TABLE IF NOT EXISTS Products (
Id BIGINT PRIMARY KEY AUTOINCREMENT,
Name VARCHAR(200) NOT NULL,
Description TEXT,
Price DECIMAL(10, 2) NOT NULL,
Stock INT NOT NULL DEFAULT 0,
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
""";
await command.ExecuteNonQueryAsync();
// Index for product search
command.CommandText = """
CREATE INDEX IF NOT EXISTS IX_Products_Name
ON Products(Name)
""";
await command.ExecuteNonQueryAsync();
}
}
Seed Data
private async Task SeedDataAsync()
{
using var command = _connection.CreateCommand();
// Check if data exists
command.CommandText = "SELECT COUNT(*) FROM Users";
var count = Convert.ToInt64(await command.ExecuteScalarAsync());
if (count > 0)
return; // Already seeded
// Seed users
command.CommandText = """
INSERT INTO Users (Name, Email) VALUES
('Alice Johnson', 'alice@example.com'),
('Bob Smith', 'bob@example.com'),
('Carol Williams', 'carol@example.com')
""";
await command.ExecuteNonQueryAsync();
// Seed products
command.CommandText = """
INSERT INTO Products (Name, Description, Price, Stock) VALUES
('Laptop', 'High-performance laptop', 1299.99, 50),
('Mouse', 'Wireless ergonomic mouse', 49.99, 200),
('Keyboard', 'Mechanical keyboard', 149.99, 100),
('Monitor', '27-inch 4K monitor', 499.99, 30),
('Headphones', 'Noise-canceling headphones', 299.99, 75)
""";
await command.ExecuteNonQueryAsync();
Console.WriteLine("Database seeded with initial data.");
}
Key Points
| Concept | Description |
|---|---|
IF NOT EXISTS |
Makes initialization idempotent — safe to run multiple times |
AUTOINCREMENT |
Automatic ID generation |
DEFAULT CURRENT_TIMESTAMP |
Automatic creation timestamp |
| Unique Index | Prevents duplicate emails |
| Seed Check | Only seeds on first run |
Service Layer Pattern
The service layer encapsulates all database operations. Let's examine UserService as an example.
Basic Structure
public class UserService
{
private readonly WitDbConnection _connection;
public UserService(WitDbConnection connection)
{
_connection = connection;
}
// Methods for CRUD and queries...
}
Reading Data: SELECT
public async Task<List<User>> GetAllAsync()
{
var users = new List<User>();
using var command = _connection.CreateCommand();
command.CommandText = "SELECT Id, Name, Email, CreatedAt FROM Users ORDER BY Id";
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
users.Add(new User
{
Id = reader.GetInt64(0),
Name = reader.GetString(1),
Email = reader.GetString(2),
CreatedAt = reader.GetDateTime(3)
});
}
return users;
}
Reading Single Row
public async Task<User?> GetByIdAsync(long id)
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT Id, Name, Email, CreatedAt FROM Users WHERE Id = @id";
command.Parameters.Add(new WitDbParameter("@id", id));
using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new User
{
Id = reader.GetInt64(0),
Name = reader.GetString(1),
Email = reader.GetString(2),
CreatedAt = reader.GetDateTime(3)
};
}
return null;
}
Creating Data: INSERT with RETURNING
The RETURNING clause is incredibly useful — you get the inserted row back without a second query:
public async Task<User> CreateAsync(string name, string email)
{
using var command = _connection.CreateCommand();
command.CommandText = """
INSERT INTO Users (Name, Email)
VALUES (@name, @email)
RETURNING Id, Name, Email, CreatedAt
""";
command.Parameters.Add(new WitDbParameter("@name", name));
command.Parameters.Add(new WitDbParameter("@email", email));
using var reader = await command.ExecuteReaderAsync();
await reader.ReadAsync();
return new User
{
Id = reader.GetInt64(0),
Name = reader.GetString(1),
Email = reader.GetString(2),
CreatedAt = reader.GetDateTime(3)
};
}
Updating Data: UPDATE with RETURNING
public async Task<User?> UpdateAsync(long id, string name, string email)
{
using var command = _connection.CreateCommand();
command.CommandText = """
UPDATE Users
SET Name = @name, Email = @email
WHERE Id = @id
RETURNING Id, Name, Email, CreatedAt
""";
command.Parameters.Add(new WitDbParameter("@id", id));
command.Parameters.Add(new WitDbParameter("@name", name));
command.Parameters.Add(new WitDbParameter("@email", email));
using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new User
{
Id = reader.GetInt64(0),
Name = reader.GetString(1),
Email = reader.GetString(2),
CreatedAt = reader.GetDateTime(3)
};
}
return null; // User not found
}
Deleting Data: DELETE
public async Task<bool> DeleteAsync(long id)
{
using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM Users WHERE Id = @id";
command.Parameters.Add(new WitDbParameter("@id", id));
var affected = await command.ExecuteNonQueryAsync();
return affected > 0;
}
Parameterized Queries: Security First
Always use parameters to prevent SQL injection:
// ❌ NEVER do this - SQL injection vulnerability!
command.CommandText =
Loading...
quot;SELECT * FROM Users WHERE Name = '{userInput}'";
// ✅ Always use parameters
command.CommandText = "SELECT * FROM Users WHERE Name = @name";
command.Parameters.Add(new WitDbParameter("@name", userInput));
Helper Method: Reading to Entity
For cleaner code, extract the mapping logic:
private User ReadUser(WitDbDataReader reader)
{
return new User
{
Id = reader.GetInt64(reader.GetOrdinal("Id")),
Name = reader.GetString(reader.GetOrdinal("Name")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
};
}
Controllers & Endpoints
Controllers handle HTTP requests and delegate to services. Here's the pattern:
UsersController
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserService _userService;
public UsersController(UserService userService)
{
_userService = userService;
}
[HttpGet]
public async Task<ActionResult<List<UserDto>>> GetAll()
{
var users = await _userService.GetAllAsync();
return users.Select(u => new UserDto(u)).ToList();
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetById(long id)
{
var user = await _userService.GetByIdAsync(id);
if (user == null)
return NotFound();
return new UserDto(user);
}
[HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request)
{
// Check for duplicate email
var existing = await _userService.GetByEmailAsync(request.Email);
if (existing != null)
return BadRequest("Email already in use");
var user = await _userService.CreateAsync(request.Name, request.Email);
return CreatedAtAction(nameof(GetById), new { id = user.Id }, new UserDto(user));
}
[HttpPut("{id}")]
public async Task<ActionResult<UserDto>> Update(long id, [FromBody] UpdateUserRequest request)
{
var user = await _userService.UpdateAsync(id, request.Name, request.Email);
if (user == null)
return NotFound();
return new UserDto(user);
}
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(long id)
{
var deleted = await _userService.DeleteAsync(id);
if (!deleted)
return NotFound();
return NoContent();
}
}
DTO Pattern
We use DTOs to control what's exposed in the API:
// Entity (internal)
public class User
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
// DTO (external API contract)
public record UserDto(long Id, string Name, string Email, DateTime CreatedAt)
{
public UserDto(User user) : this(user.Id, user.Name, user.Email, user.CreatedAt) { }
}
// Request models
public record CreateUserRequest(string Name, string Email);
public record UpdateUserRequest(string Name, string Email);
Complete Endpoint Reference
Users API
Method
Endpoint
Description
GET
/api/users
Get all users
GET
/api/users/{id}
Get user by ID
POST
/api/users
Create new user
PUT
/api/users/{id}
Update user
DELETE
/api/users/{id}
Delete user
GET
/api/users/search?q={term}
Search by name
GET
/api/users/page?page=1&size=10
Paginated list
Products API
Method
Endpoint
Description
GET
/api/products
Get all products
GET
/api/products/{id}
Get product by ID
POST
/api/products
Create new product
PUT
/api/products/{id}
Update product
DELETE
/api/products/{id}
Delete product
GET
/api/products/search?q={term}
Search by name
GET
/api/products/statistics
Get price/stock statistics
GET
/api/products/price-range?min=10&max=100
Filter by price
POST
/api/products/bulk
Bulk import products
PATCH
/api/products/{id}/stock
Update stock only
Transaction Management
For operations that modify multiple rows, transactions ensure atomicity — either all changes succeed or none do.
Bulk Import with Transaction
The /api/products/bulk endpoint imports multiple products in a single transaction:
[HttpPost("bulk")]
public async Task<ActionResult<BulkImportResult>> BulkImport(
[FromBody] List<CreateProductRequest> products)
{
if (products == null || products.Count == 0)
return BadRequest("No products provided");
var result = await _productService.BulkImportAsync(products);
return Ok(result);
}
Service Implementation
public async Task<BulkImportResult> BulkImportAsync(List<CreateProductRequest> products)
{
var imported = 0;
var failed = 0;
var errors = new List<string>();
// Start transaction
using var transaction = _connection.BeginTransaction();
try
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
foreach (var product in products)
{
try
{
command.CommandText = """
INSERT INTO Products (Name, Description, Price, Stock)
VALUES (@name, @desc, @price, @stock)
""";
command.Parameters.Clear();
command.Parameters.Add(new WitDbParameter("@name", product.Name));
command.Parameters.Add(new WitDbParameter("@desc", product.Description ?? ""));
command.Parameters.Add(new WitDbParameter("@price", product.Price));
command.Parameters.Add(new WitDbParameter("@stock", product.Stock));
await command.ExecuteNonQueryAsync();
imported++;
}
catch (Exception ex)
{
failed++;
errors.Add(
Loading...
quot;Failed to import '{product.Name}': {ex.Message}");
}
}
// Commit all changes
transaction.Commit();
}
catch (Exception)
{
// Rollback on any error
transaction.Rollback();
throw;
}
return new BulkImportResult(imported, failed, errors);
}
Result Model
public record BulkImportResult(
int Imported,
int Failed,
List<string> Errors
);
Testing Bulk Import
curl -X POST http://localhost:5184/api/products/bulk \
-H "Content-Type: application/json" \
-d '[
{"name": "USB Cable", "description": "USB-C cable", "price": 9.99, "stock": 500},
{"name": "Webcam", "description": "HD webcam", "price": 79.99, "stock": 50},
{"name": "Mouse Pad", "description": "Large gaming mouse pad", "price": 19.99, "stock": 200}
]'
Response:
{
"imported": 3,
"failed": 0,
"errors": []
}
Transaction Best Practices
Practice
Reason
Keep transactions short
Reduces lock contention
Always use try/catch/finally
Ensures rollback on error
Batch similar operations
Reduces round-trips
Use command.Parameters.Clear()
Reuse command in loops
Commit explicitly
Don't rely on implicit commit
Advanced Queries
Beyond basic CRUD, the sample demonstrates several advanced query patterns.
Search with LIKE
public async Task<List<User>> SearchByNameAsync(string searchTerm)
{
var users = new List<User>();
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT Id, Name, Email, CreatedAt
FROM Users
WHERE Name LIKE @term
ORDER BY Name
""";
command.Parameters.Add(new WitDbParameter("@term",
Loading...
quot;%{searchTerm}%"));
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
users.Add(ReadUser(reader));
}
return users;
}
Usage:
curl "http://localhost:5184/api/users/search?q=alice"
Pagination with LIMIT/OFFSET
public async Task<(List<User> Users, int TotalCount)> GetPagedAsync(int page, int pageSize)
{
using var command = _connection.CreateCommand();
// Get total count
command.CommandText = "SELECT COUNT(*) FROM Users";
var totalCount = Convert.ToInt32(await command.ExecuteScalarAsync());
// Get page
command.CommandText = """
SELECT Id, Name, Email, CreatedAt
FROM Users
ORDER BY Id
LIMIT @limit OFFSET @offset
""";
command.Parameters.Add(new WitDbParameter("@limit", pageSize));
command.Parameters.Add(new WitDbParameter("@offset", (page - 1) * pageSize));
var users = new List<User>();
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
users.Add(ReadUser(reader));
}
return (users, totalCount);
}
Usage:
curl "http://localhost:5184/api/users/page?page=1&size=10"
Response:
{
"items": [...],
"page": 1,
"pageSize": 10,
"totalCount": 25,
"totalPages": 3
}
Statistics with Aggregations
public async Task<ProductStatistics> GetStatisticsAsync()
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT
COUNT(*) AS TotalProducts,
SUM(Stock) AS TotalStock,
AVG(Price) AS AveragePrice,
MIN(Price) AS MinPrice,
MAX(Price) AS MaxPrice,
SUM(Price * Stock) AS TotalInventoryValue
FROM Products
""";
using var reader = await command.ExecuteReaderAsync();
await reader.ReadAsync();
return new ProductStatistics
{
TotalProducts = reader.GetInt32(0),
TotalStock = reader.GetInt32(1),
AveragePrice = reader.GetDecimal(2),
MinPrice = reader.GetDecimal(3),
MaxPrice = reader.GetDecimal(4),
TotalInventoryValue = reader.GetDecimal(5)
};
}
Usage:
curl http://localhost:5184/api/products/statistics
Response:
{
"totalProducts": 5,
"totalStock": 455,
"averagePrice": 459.99,
"minPrice": 49.99,
"maxPrice": 1299.99,
"totalInventoryValue": 156746.50
}
Price Range Filtering
public async Task<List<Product>> GetByPriceRangeAsync(decimal minPrice, decimal maxPrice)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT Id, Name, Description, Price, Stock, CreatedAt
FROM Products
WHERE Price >= @min AND Price <= @max
ORDER BY Price
""";
command.Parameters.Add(new WitDbParameter("@min", minPrice));
command.Parameters.Add(new WitDbParameter("@max", maxPrice));
var products = new List<Product>();
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
products.Add(ReadProduct(reader));
}
return products;
}
Usage:
curl "http://localhost:5184/api/products/price-range?min=50&max=200"
Testing with Swagger
The built-in Swagger UI makes testing effortless.
Using Swagger UI
- Navigate to http://localhost:5184
- Click on any endpoint to expand it
- Click "Try it out"
- Fill in parameters (if required)
- Click "Execute"
- View the response
Example: Creating a User
- Expand
POST /api/users
- Click "Try it out"
- Enter the request body:
{
"name": "John Doe",
"email": "john@example.com"
}
- Click "Execute"
- See the response:
{
"id": 4,
"name": "John Doe",
"email": "john@example.com",
"createdAt": "2025-01-15T14:30:00"
}
Testing with curl
# Get all users
curl http://localhost:5184/api/users
# Create a user
curl -X POST http://localhost:5184/api/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
# Update a user
curl -X PUT http://localhost:5184/api/users/4 \
-H "Content-Type: application/json" \
-d '{"name": "John Smith", "email": "john.smith@example.com"}'
# Delete a user
curl -X DELETE http://localhost:5184/api/users/4
# Search products
curl "http://localhost:5184/api/products/search?q=laptop"
# Get statistics
curl http://localhost:5184/api/products/statistics
# Bulk import
curl -X POST http://localhost:5184/api/products/bulk \
-H "Content-Type: application/json" \
-d '[{"name":"Item 1","price":10,"stock":100},{"name":"Item 2","price":20,"stock":50}]'
Summary
In this tutorial, we built a complete REST API using WitDatabase's ADO.NET provider:
Component
What We Learned
Connection Setup
Singleton pattern, graceful shutdown
Initialization
Schema creation, seed data
Service Layer
CRUD with WitDbCommand and WitDbParameter
Controllers
REST endpoints, DTO pattern
Transactions
Bulk operations with commit/rollback
Advanced Queries
Search, pagination, aggregations
When to Use This Approach
✅ Use ADO.NET when:
- You need precise SQL control
- Performance is critical
- You have complex queries
- You're comfortable with SQL
❌ Consider EF Core when:
- Rapid development is priority
- You want automatic change tracking
- You need migrations
- Queries are straightforward CRUD
Get the Code
git clone https://github.com/dmitrat/WitDatabase.git
cd WitDatabase/Samples/OutWit.Database.Samples.WebApi
dotnet run
Have questions? Open an issue on GitHub or star the repo if you found this useful!