For most personal and enterprise apps, I tend to use something like Microsoft SQL or Postgres for data storage and persistence, but there are occasions when it may also make sense to use a document database. Previously, I've used Firebase in some Flutter apps, but wanted to see how it is working with Cosmos DB in a .Net application.
Install the NuGet Packages
First, we need to create the project and add the cosmos DB package.
dotnet new webapi --name CosmosDB
cd CosmosDb
dotnet add package Microsoft.EntityFrameworkCore.Cosmos
Download / Install Cosmos DB Emulator
In order to utilize Cosmos DB, we either have to create an account and database on Azure (there's a free tier), or we can download the emulator and run everything locally. I decided to use the emulator.
Once it's up and running, you should be at a welcome page that displays the primary connection string.
There's also an explorer that you can use to view and create databases or containers. For now, we'll just create a new database.
Create a DbContext and Entities
Similar to using SQL, we'll need to create our DbContext and Entity Classes. For the demo I decided to use Customers, Orders, and Addresses.
public class Customer
{
[Key]
public Guid? Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public DefaultAddress? DefaultBillingAddress { get; set; }
public DefaultAddress? DefaultShippingAddress { get; set; }
public ICollection<Order>? RecentOrders { get; set; }
}
public class Order
{
[Key]
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid TrackingNumber { get; set; }
public DefaultAddress ShipToAddress { get; set; }
}
public class Address
{
[Key]
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
}
public class DefaultAddress
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public DefaultAddress() { }
public DefaultAddress(Address address)
{
Street = address.Street;
City = address.City;
State = address.State;
ZipCode = address.ZipCode;
}
}
Notice that I have two different addresses - Address and DefaultAddress. This is because I want to store all of my addresses in a specific container called "Address", but also want to have a "Default Address" that is part of the customer object / document for easy access. I'm also using Guids for the ID because in Cosmos DB, the key of a document must be a string.
For the DbContext, we define our tables using DbSets and setup our model builder
public class DemoContext : DbContext
{
public DemoContext(DbContextOptions<DemoContext> options) : base(options)
{
}
public DbSet<Customer> Customers { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<Address> Addresses { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
builder.HasDefaultContainer("Orders");
builder.Entity<Customer>()
.ToContainer(nameof(Customer))
.HasPartitionKey(c => c.Id)
.HasNoDiscriminator();
builder.Entity<Order>()
.ToContainer(nameof(Order))
.HasPartitionKey(o => o.CustomerId)
.HasNoDiscriminator();
builder.Entity<Address>()
.ToContainer(nameof(Address))
.HasPartitionKey(c => c.CustomerId)
.HasNoDiscriminator();
}
}
In OnModelCreating, we're specifying which container the entities should live in by using .ToContainer(), and setting all of the the partition keys to the customer Id
The HasNoDiscriminator() line prevents a descriminator from being created on the objects because there will only ever be a single object type in each container. If we had multiple objects types in the container, we could specify a discriminator, which would then be combined with the primary key to create an "id" property, if it doesn't already exist.
Add the DbContext to Startup
Since everything's defined, we can add the DbContext during startup
builder.Services.AddDbContext<DemoContext>(options =>
options.UseCosmos(
"ConnectionString"
"Database"
));
The connection string will either come from the Azure Portal or the Emulator home screen as shown above.
Create the Containers
During startup, we should make sure that all of the containers are created and any seed data is inserted. This happens by calling EnsureCreatedAsync(). For simplicity and demonstration purposes, I just added a simple check inside the controller, but this would live somewhere else in a real application.
[Route("cosmos")]
public class CosmosController : ControllerBase
{
private readonly DemoContext _dbContext;
private static bool _ensureCreated { get; set; } = false;
public CosmosController(DemoContext dbContext)
{
_dbContext = dbContext;
if (!_ensureCreated)
{
_dbContext.Database.EnsureCreated();
_ensureCreated = true;
}
}
}
Once this runs, we should see all of our containers defined in our OnModelCreating method
Interact with the Database
From here, we can interact with CosmosDB through EF Core like normal.
[HttpGet("customer")]
public async Task<IActionResult> GetCustomers()
{
return Ok(await _dbContext.Customers.ToListAsync());
}
[HttpPost("customer")]
public async Task<IActionResult> CreateCustomer([FromBody] NewCustomer customer)
{
var newCustomer = new Customer
{
Name = customer.Name,
Email = customer.Email
};
var setAddresses = customer.Addresses != null && customer.Addresses.Any();
if(setAddresses)
{
newCustomer.DefaultShippingAddress = new DefaultAddress(customer.Addresses[0]);
newCustomer.DefaultBillingAddress = new DefaultAddress(customer.Addresses[0]);
}
var addedCustomer = await _dbContext.AddAsync(newCustomer);
await _dbContext.SaveChangesAsync();
if(setAddresses)
{
foreach (var address in customer.Addresses)
{
address.CustomerId = addedCustomer.Entity.Id ?? Guid.NewGuid();
await _dbContext.AddAsync(thing);
}
await _dbContext.SaveChangesAsync();
}
return Ok(addedCustomer.Entity);
}
One thing I don't particularly like about CosmosDB is the fact that costs center around RUs, which vary based on each read, write, and query. This makes it very difficult to know what your actual costs are going to be once going live with an application.
To help estimate this during development, you can use EnableSensitiveDataLogging() to get the RUs for each request, which can at least give you an idea of how your queries are doing and if any data / containers / queries need to be restructured.
builder.Services.AddDbContext<DemoContext>(options =>
{
options.UseCosmos(
"ConnectionString",
"Database"
);
#if DEBUG
options.EnableSensitiveDataLogging();
#endif
});
For the two endpoints above, we get the following:
POST
{
"name": "John Doe",
"email": "johndoe@test.com",
"addresses": []
}
RESPONSE
{
"id": "7e012212-ed2e-4dd7-987a-08db14ce946e",
"name": "John Doe",
"email": "johndoe@test.com",
"defaultBillingAddress": null,
"defaultShippingAddress": null,
"recentOrders": null
}
POST
{
"name": "Jane Doe",
"email": "janedoe@test.com",
"addresses": [
{
"street": "123 Main St",
"city": "Somewhere",
"state": "ThatPlace",
"zipCode": "12345"
}
]
}
RESPONSE
{
"id": "742cec7b-440f-482a-0ef2-08db14cfddbe",
"name": "Jane Doe",
"email": "janedoe@test.com",
"defaultBillingAddress": {
"street": "123 Main St",
"city": "Somewhere",
"state": "ThatPlace",
"zipCode": "12345"
},
"defaultShippingAddress": {
"street": "123 Main St",
"city": "Somewhere",
"state": "ThatPlace",
"zipCode": "12345"
},
"recentOrders": null
}
GET
RESPONSE
[
{
"id": "7e012212-ed2e-4dd7-987a-08db14ce946e",
"name": "John Doe",
"email": "johndoe@test.com",
"defaultBillingAddress": null,
"defaultShippingAddress": null,
"recentOrders": null
},
{
"id": "742cec7b-440f-482a-0ef2-08db14cfddbe",
"name": "Jane Doe",
"email": "janedoe@test.com",
"defaultBillingAddress": {
"street": "123 Main St",
"city": "Somewhere",
"state": "ThatPlace",
"zipCode": "12345"
},
"defaultShippingAddress": {
"street": "123 Main St",
"city": "Somewhere",
"state": "ThatPlace",
"zipCode": "12345"
},
"recentOrders": null
}
]
So for the three requests above,
- Creating a customer with no address
- Creating a customer with a single address,
- Retrieving a list of all customers
we used a total of 27.62 RUs. If we have a general idea of how many times we'll be executing those requests per second or per minute, we can get an idea of what kind of throughput we'll need and the cost.
Notes
While working on the demo, I did notice that if you try to save the same object as two different properties, you run into an error when saving. For example, in the case of the Customer's default addresses, trying to do something like this will fail:
var defaultAddress = new Address
{
Street = address.Street;
City = address.City;
State = address.State;
ZipCode = address.ZipCode;
}
newCustomer.DefaultShippingAddress = defaultAddress;
newCustomer.DefaultBillingAddress = defaultAddress;
but this will work:
newCustomer.DefaultShippingAddress = new Address
{
Street = address.Street;
City = address.City;
State = address.State;
ZipCode = address.ZipCode;
};
newCustomer.DefaultBillingAddress = new Address
{
Street = address.Street;
City = address.City;
State = address.State;
ZipCode = address.ZipCode;
}
Some other helpful links I found are below:
Comments