Code-first is the workflow most EF Core teams use today: you define plain C# classes for your domain, EF inspects them, and the database schema follows from the code. This lesson covers what code-first actually means, the conventions EF Core applies without any configuration, the two ways to customize mappings (data annotations and Fluent API), and a few extras like owned types and value converters that round out the picture.
EF has carried three workflows over its history, and the names still surface in interviews even though only one is in active use.
Database-first starts with an existing database. You point a tool at the schema, it scrapes tables and columns, and generates C# classes from what it finds. Useful when you inherit a legacy database, but the generated classes are noisy and any schema change means regenerating them.
Model-first uses a visual designer (the old .edmx file) to draw entities, then generates both code and SQL from the diagram. EF Core dropped this entirely. If someone mentions .edmx, they're talking about EF6 or earlier.
Code-first flips the relationship. C# classes are the source of truth, and the database schema is derived from them. You edit a class, EF figures out the SQL to keep the database in sync, and migration files capture each change in version control like the rest of the code.
Code-first won because it fits how modern teams actually work. The schema lives next to the code that uses it, code review covers schema changes, and you don't need a separate tool to evolve the database. EF Core ships with code-first as the default workflow and the database-first scaffolding tool (dotnet ef dbcontext scaffold) as an escape hatch for legacy schemas.
Code-first does not mean "EF generates a good schema for you." The conventions are reasonable, but performance-sensitive tables (large indexes, partitioning, specific column types) still need explicit configuration. Treat the generated schema as a starting point to review, not a finished design.
The "PO" in POCO stands for Plain Old. EF Core entities are just C# classes. They don't inherit from any EF base type, don't implement any required interface, and don't carry any attributes by default. That's deliberate. The domain model stays unit-testable without an EF reference, and you can move it between projects without dragging EF along.
A minimal entity for an e-commerce product looks like this.
That's it. No [Entity] attribute, no EntityBase parent, no IEntity interface. EF inspects this class through reflection when the DbContext first runs and works out the mapping from the names and types alone.
Three things matter for EF to map an entity:
init.The string Name { get; set; } = string.Empty; pattern matters here. With nullable reference types enabled (the default on .NET 8 templates), a non-nullable string property would warn about uninitialized state. The empty-string default silences that warning while still mapping to a NOT NULL column in the database.
A more complete e-commerce example covering the entities we'll work with through this lesson:
These classes have no relationships yet. Linking Product to Category or Review to Customer is the job of lesson 05.
EF Core's biggest practical benefit is that you can ship a working schema without writing a single attribute or Fluent API call. The model builder runs a set of conventions over your classes, and most of the time the result is what you'd write by hand.
The conventions worth memorizing:
| Convention | Default Behavior |
|---|---|
| Primary key | A property named Id or <EntityName>Id (case-insensitive) becomes the primary key. |
| Identity column | An integer PK is configured as an identity / auto-increment column. |
| Table name | The table is named after the DbSet<T> property on DbContext (typically pluralized). |
| Column name | Each column takes the property name as-is. |
| Column type | Mapped from the CLR type: int to int, decimal to decimal(18,2), string to nvarchar(max) on SQL Server. |
| Nullability | Non-nullable CLR types become NOT NULL columns. Nullable reference types (string?) and Nullable<T> become NULL. |
| Schema | Default schema is dbo on SQL Server, public on PostgreSQL. |
If we plug Product into a DbContext like this:
The conventions produce roughly this schema on SQL Server:
Two things stand out and usually get changed. nvarchar(max) is what you get for any string, which means EF will store a 2GB column for what is probably a 100-character name. And decimal(18,2) is fine for prices but wrong for, say, an exchange rate. These are the gaps that data annotations and Fluent API close.
Data annotations are attributes from System.ComponentModel.DataAnnotations and System.ComponentModel.DataAnnotations.Schema that override conventions inline on the entity class. They're the simplest way to refine a mapping when the default isn't what you want.
The annotations EF Core respects most often:
What each attribute does:
| Attribute | Effect |
|---|---|
[Key] | Marks the property as the primary key. Only needed if the name doesn't match the convention. |
[Required] | On a nullable reference type or Nullable<T>, makes the column NOT NULL. |
[MaxLength(n)] | Maps a string to nvarchar(n) instead of nvarchar(max). |
[Column("name")] | Overrides the column name. |
[Column(TypeName = "...")] | Specifies the exact SQL type, like decimal(10,2) or varchar(100). |
[Table("name", Schema = "...")] | Overrides table name and schema. |
[ConcurrencyCheck] | Includes this column in the WHERE clause on UPDATE/DELETE for optimistic concurrency. |
[Timestamp] | Marks a byte[] as a row-version column. SQL Server populates and updates it automatically. |
[NotMapped] | Tells EF to ignore the property. The column won't exist in the database. |
A few subtleties trip people up. [Required] on a non-nullable int is redundant: EF already creates a NOT NULL column because the CLR type can't be null. [Required] matters for reference types (string, custom classes) and nullable value types (int?). For nullable reference types, string Name { get; set; } (non-nullable) is already NOT NULL thanks to nullable annotations being on by default in .NET 8 templates; [Required] becomes explicit reinforcement rather than the source of truth.
[Timestamp] and [ConcurrencyCheck] are easy to confuse. Both enable optimistic concurrency, the pattern where EF detects whether two users are editing the same row by checking that the row hasn't changed since you read it. [Timestamp] is a database-managed rowversion column that updates on every write automatically. [ConcurrencyCheck] is a column you manage yourself; EF just includes it in the WHERE clause. Use [Timestamp] when your provider supports rowversion (SQL Server does; PostgreSQL doesn't have an exact equivalent and needs a different approach).
Data annotations are easy to read because the mapping lives right next to the property. Their limits show up fast. You can't express "this property has a default value of GETUTCDATE()", you can't configure value converters, and you can't express compound configuration cleanly. For anything beyond simple shaping, Fluent API takes over.
OnModelCreatingThe Fluent API expresses the same configuration as data annotations, but in code inside the DbContext. Every entity, property, relationship, and index you can configure with an annotation has a Fluent equivalent, plus a lot more that has no annotation form at all.
Configuration lives in the OnModelCreating method:
The same configuration we wrote with attributes is here as method calls. The mapping table makes the correspondence direct:
| Data Annotation | Fluent API |
|---|---|
[Key] | .HasKey(e => e.Id) |
[Required] | .Property(e => e.X).IsRequired() |
[MaxLength(n)] | .Property(e => e.X).HasMaxLength(n) |
[Column("name")] | .Property(e => e.X).HasColumnName("name") |
[Column(TypeName = "...")] | .Property(e => e.X).HasColumnType("...") |
[Table("t", Schema = "s")] | .ToTable("t", schema: "s") |
[ConcurrencyCheck] | .Property(e => e.X).IsConcurrencyToken() |
[Timestamp] | .Property(e => e.X).IsRowVersion() |
[NotMapped] | .Ignore(e => e.X) |
| (no equivalent) | .HasDefaultValueSql("...") |
| (no equivalent) | .HasComputedColumnSql("...") |
| (no equivalent) | .HasConversion<TConverter>() |
Both APIs produce identical schemas and the same SQL at runtime. Pick based on readability and the kind of configuration needed, not on performance. The model is built once at startup and cached.
The Fluent API wins in three places annotations can't reach: server-side defaults, computed columns, and value converters.
Both approaches work. The choice usually comes down to how much configuration you have and where you want it to live.
| Factor | Data Annotations | Fluent API |
|---|---|---|
| Where the config lives | On the entity class, mixed with properties | Centralized in DbContext or a configuration class |
| Visibility from the entity | High (you see it next to the property) | Low (you have to look up the context) |
| Required attributes per entity | Adds using directives and noise | Entity stays clean |
| Configurable features | Subset of EF's capabilities | All of EF's capabilities |
| Domain model dependencies | Brings System.ComponentModel.DataAnnotations reference into the entity assembly | Entity has no EF reference |
| Suitability for shared domain models | Poor (couples model to EF) | Good (model is pure POCOs) |
| Suitability for many entities | Gets crowded fast | Scales cleanly with configuration classes |
The honest recommendation: use Fluent API for anything serious. Annotations are fine for tiny projects or for quick prototyping, but the moment you have more than a handful of entities, a separate property mapping per entity, or any of the Fluent-only features, the configuration is easier to maintain in one place.
A second consideration is layering. If your domain entities live in a project that intentionally doesn't reference EF (a classic clean-architecture pattern), annotations are off the table because the attributes are in System.ComponentModel.DataAnnotations.Schema, which is fine to reference, but [Required] and [MaxLength] come from the non-Schema namespace and are widely used elsewhere too. Fluent API keeps the domain entities pure POCOs with zero attribute noise, which most teams prefer for testability and reuse.
A reasonable middle ground that some teams adopt: keep [Required] and [MaxLength] as annotations because they double as validation hints for ASP.NET Core model binding, and put everything else (column types, defaults, converters, table names, schemas) in Fluent API. The annotations carry meaning for both EF and the web layer; the Fluent stuff is database-only.
IEntityTypeConfiguration<T> and Configuration ClassesOnModelCreating gets long fast. Twenty entities with five or six Fluent calls each turns into a 200-line method. The standard fix is to extract each entity's configuration into its own class that implements IEntityTypeConfiguration<T>.
One class per entity, sitting next to (or under) the entity it configures. Each configuration is testable on its own and easy to find. The DbContext shrinks to almost nothing.
ApplyConfigurationsFromAssembly scans the assembly for every type that implements IEntityTypeConfiguration<T> and registers it. Add a new entity, write one configuration class, the context picks it up automatically. No edits to OnModelCreating, no risk of forgetting to wire it up.
The reflection scan happens once when the model is first built (typically on the first query). It's fast, but with hundreds of entities and frequent app restarts during development, it's noticeable. For production, this is a non-issue because the model is cached for the process lifetime.
If you'd rather be explicit, you can register each one individually:
Slightly more typing, but it provides control over which configurations apply (useful when one assembly contains entities for multiple contexts). For most apps, the assembly scan fits.
Some properties on an entity are not entities of their own. A shipping Address belongs to a Customer, has no identity outside that customer, and never needs to be queried separately. Modeling Address as its own entity with its own table and PK is overkill. EF Core has two features for this: owned types and complex types.
An owned type is a type whose data is stored in the owner's table by default, but EF still tracks it as a separate object in the model. You configure it with OwnsOne or OwnsMany.
In the configuration:
The address columns sit inline on the Customers table. There's no separate Addresses table, no foreign key. The Address class is a value object: it has no identity, it's a piece of customer data, and it travels with its parent.
Complex types, introduced in EF Core 8, are similar but cleaner. They don't participate in change tracking as separate objects; they're treated as a structured value of the parent. The configuration is ComplexProperty instead of OwnsOne:
EF infers the rest from the type's properties. Complex types are the recommended choice for new code when the nested type has no identity and no collection nesting requirements. Owned types fit when an owned collection (OwnsMany) is needed or when the owned entity needs its own table.
The exact semantics aren't critical right now. The important point: when a piece of data conceptually belongs to its owner and has no life of its own (addresses, money amounts, date ranges, audit info), there's a way to model it without creating a separate entity and table.
The last set of mappings worth covering is the database-side stuff: server-generated defaults, computed columns, and value converters that translate between CLR types and column types.
Default values assign a value when no explicit one is provided. Two flavors: a literal default and a SQL expression.
HasDefaultValue is a constant baked into the column definition; HasDefaultValueSql is an expression the database evaluates on each insert. Both apply only when EF sends an insert with no value for that column.
Computed columns are columns whose value the database computes from other columns. Useful for derived fields you want available in queries without computing them in C#.
The PriceWithTax property on Product (likely [NotMapped] for the getter on the C# side, or a read-only property) ends up as a SQL Server computed column. Reads come from the database; writes are rejected. Stored or non-stored is configurable: .HasComputedColumnSql("Price * 1.08", stored: true) materializes the value, which is slower to write but faster to query.
Value converters translate between CLR types and database types. The classic use case is storing an enum as a string instead of an integer.
By default, Status maps to an int. If you'd rather store it as a string (more readable in the database, more resilient to enum-value reordering), wire up a converter:
Behind the scenes EF inserts "Pending" instead of 0 and reads it back the same way. For arbitrary conversions, there's a generic overload that takes two lambdas: one to serialize and one to deserialize.
Value converters run on every read and every write for the affected column. A converter that joins or splits strings adds CPU per row. For 10 rows in an admin page, irrelevant. For a query that returns 100,000 rows, measurable. The bigger pitfall is that EF can't translate operations on the converted value to SQL. Filtering with Where(p => p.Tags.Contains("sale")) after a converter that stores tags as CSV either fails or falls back to client-side evaluation, which materializes every row.
10 quizzes