Every Claude Code demo shows the same thing: “Write me a function that does X.” And Claude writes a function. Impressive for thirty seconds.

But that’s not how you build software. Real work starts with a ticket that’s half-specified, a codebase with its own patterns and opinions, and a dozen small decisions that don’t show up in any demo. You need a data model, endpoints, validation, tests, a review, and a PR that your colleagues can actually understand.

I wanted to see what it looks like to take Claude Code through that entire process. Not a toy example — a real feature from ticket to merged pull request.


The ticket

Here’s the GitHub issue I started with:

As a user, I want to manage my notification preferences (email, push, SMS) so I can control what notifications I receive.

Acceptance criteria:

  • Users can retrieve their current notification preferences
  • Users can update preferences for each channel (email, push, SMS) independently
  • Each channel can be enabled/disabled and has a frequency setting (immediate, daily digest, weekly digest)
  • Default preferences are created when a user has none
  • Input validation with clear error messages

A realistic ticket. Not too complex, not trivial. It touches the database, the API layer, validation, and has enough edge cases to be interesting.


Phase 1: Understanding and planning

I didn’t start with “build this feature.” I started with context.

“Read the existing codebase structure — especially how other entities are modeled, how controllers are organized, and what patterns we use for validation and DTOs. Then propose an implementation plan for this notification preferences feature.”

Claude read through the project. It found the existing UserProfile entity, noticed we use the repository pattern with EF Core, spotted our FluentValidation setup, and saw that we use AutoMapper for DTO mapping. It came back with a plan:

  1. Create NotificationPreference entity with EF Core configuration
  2. Add migration
  3. Create request/response DTOs
  4. Add FluentValidation validator
  5. Build controller endpoints (GET and PUT)
  6. Add AutoMapper profile
  7. Write xUnit tests
  8. Create PR

The plan matched our existing patterns. That’s the key benefit of having Claude read the codebase first — it doesn’t invent new patterns, it follows yours.


Phase 2: Data model and migration

“Create the NotificationPreference entity following the same patterns as UserProfile. Include EF Core configuration. Each user has one set of preferences with settings for email, push, and SMS channels.”

Claude generated the entity:

public class NotificationPreference
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public User User { get; set; } = null!;

    public bool EmailEnabled { get; set; } = true;
    public NotificationFrequency EmailFrequency { get; set; } = NotificationFrequency.Immediate;

    public bool PushEnabled { get; set; } = true;
    public NotificationFrequency PushFrequency { get; set; } = NotificationFrequency.Immediate;

    public bool SmsEnabled { get; set; } = false;
    public NotificationFrequency SmsFrequency { get; set; } = NotificationFrequency.Daily;

    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}

public enum NotificationFrequency
{
    Immediate,
    Daily,
    Weekly
}

One thing I corrected: Claude initially put SMS defaults to true. I changed that — SMS costs money, so the default should be false. This is exactly the kind of business decision Claude can’t make for you. It doesn’t know your SMS bill.

Claude then added the EF Core configuration and created the migration with dotnet ef migrations add AddNotificationPreferences. Clean.


Phase 3: API endpoints

“Build the API endpoints for notification preferences. GET to retrieve current preferences (create defaults if none exist), PUT to update them. Follow our existing controller patterns. Include DTOs and FluentValidation.”

Claude created the DTOs:

public class NotificationPreferenceResponse
{
    public bool EmailEnabled { get; set; }
    public string EmailFrequency { get; set; } = string.Empty;
    public bool PushEnabled { get; set; }
    public string PushFrequency { get; set; } = string.Empty;
    public bool SmsEnabled { get; set; }
    public string SmsFrequency { get; set; } = string.Empty;
}

public class UpdateNotificationPreferenceRequest
{
    public bool EmailEnabled { get; set; }
    public string EmailFrequency { get; set; } = string.Empty;
    public bool PushEnabled { get; set; }
    public string PushFrequency { get; set; } = string.Empty;
    public bool SmsEnabled { get; set; }
    public string SmsFrequency { get; set; } = string.Empty;
}

And the controller:

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class NotificationPreferencesController : ControllerBase
{
    private readonly INotificationPreferenceService _service;

    public NotificationPreferencesController(INotificationPreferenceService service)
    {
        _service = service;
    }

    [HttpGet]
    public async Task<ActionResult<NotificationPreferenceResponse>> Get()
    {
        var userId = User.GetUserId();
        var preferences = await _service.GetOrCreateDefaultAsync(userId);
        return Ok(preferences);
    }

    [HttpPut]
    public async Task<ActionResult<NotificationPreferenceResponse>> Update(
        UpdateNotificationPreferenceRequest request)
    {
        var userId = User.GetUserId();
        var updated = await _service.UpdateAsync(userId, request);
        return Ok(updated);
    }
}

I had to ask Claude to fix one thing: it originally used string for the frequency values in the DTOs. That works, but our existing codebase uses strongly-typed enums in requests and lets the JSON serializer handle conversion. Small course correction, two seconds.


Phase 4: Tests

“Write xUnit tests for the NotificationPreferenceService. Cover the happy path for get and update, the case where no preferences exist yet (should create defaults), and validation of invalid frequency values. Use NSubstitute for mocking.”

Claude produced a solid test class. Here’s one of the more interesting tests:

[Fact]
public async Task GetOrCreateDefault_WhenNoPreferencesExist_CreatesDefaults()
{
    // Arrange
    var userId = 42;
    _repository.GetByUserIdAsync(userId)
        .Returns(Task.FromResult<NotificationPreference?>(null));

    // Act
    var result = await _sut.GetOrCreateDefaultAsync(userId);

    // Assert
    result.EmailEnabled.Should().BeTrue();
    result.SmsEnabled.Should().BeFalse();
    result.EmailFrequency.Should().Be(NotificationFrequency.Immediate);
    await _repository.Received(1).AddAsync(Arg.Any<NotificationPreference>());
    await _unitOfWork.Received(1).SaveChangesAsync();
}

[Fact]
public async Task Update_WithInvalidFrequency_ThrowsValidationException()
{
    // Arrange
    var userId = 42;
    var existing = CreateDefaultPreferences(userId);
    _repository.GetByUserIdAsync(userId).Returns(existing);

    var request = new UpdateNotificationPreferenceRequest
    {
        EmailEnabled = true,
        EmailFrequency = NotificationFrequency.Immediate,
        PushEnabled = true,
        PushFrequency = NotificationFrequency.Daily,
        SmsEnabled = true,
        SmsFrequency = (NotificationFrequency)99 // invalid
    };

    // Act
    var act = () => _sut.UpdateAsync(userId, request);

    // Assert
    await act.Should().ThrowAsync<ValidationException>();
}

Claude generated eight tests in total. Seven passed immediately. One failed because Claude assumed SaveChangesAsync was on the repository, but in our codebase it’s on the unit of work. I pointed out the error and Claude fixed it in seconds.

This is what I mean by “course corrections.” Claude doesn’t get everything right the first time. But fixing these issues takes seconds, not minutes.


Phase 5: Review and refine

Here’s where it gets interesting. I asked Claude to review its own work.

“Review the notification preferences feature we just built. Check for: missing validation, security issues, performance concerns, and anything that doesn’t match the existing codebase patterns.”

Claude found three things:

  1. Missing concurrency handling. The GET endpoint that creates defaults could cause a race condition if two requests come in simultaneously. Claude added a unique constraint on UserId and a try-catch for DbUpdateException.
  2. No rate limiting on the PUT endpoint. Our other mutation endpoints have rate limiting attributes. Claude added [RateLimit("standard")] to match.
  3. Missing CancellationToken propagation. Our codebase passes cancellation tokens through, but Claude had omitted them. It added CancellationToken parameters to the service and repository methods.

I wouldn’t have caught the rate limiting one. That’s the value of asking Claude to cross-reference against the existing codebase — it spots inconsistencies that you’d miss because you wrote the original code months ago.


Phase 6: PR and documentation

“Create a feature branch, commit the changes with a clear message, and draft a PR description that explains the feature, the design decisions, and how to test it.”

Claude created the branch feature/notification-preferences, made a clean commit, and wrote a PR description that included:

  • Summary of the feature
  • API endpoint documentation (routes, request/response examples)
  • Design decisions (why defaults are created on first GET, why SMS defaults to off)
  • Test coverage summary
  • Migration instructions

The PR description was actually better than what I’d write myself. Claude included curl examples for testing the endpoints and a note about the race condition fix. My PRs usually say “added notification preferences” and leave it at that.


What I learned

After walking through this entire process, a few things became clear.

Where Claude helped most: Boilerplate code (entity, DTOs, controller scaffolding), test generation, and self-review. These are the parts where the patterns are established and the work is mostly mechanical. Claude saved me roughly two hours of typing.

Where I still needed judgment: The SMS default value. The decision to use enums instead of strings. Knowing that our codebase uses a unit of work pattern. These are context and business decisions that Claude can’t make alone.

Where Claude surprised me: The review phase. Having Claude check its own work against the existing codebase caught real issues I would have missed. The race condition, the missing rate limiting, the cancellation tokens — these aren’t things I typically check in my own code reviews either.

What didn’t work perfectly: Claude’s first attempt at tests assumed the wrong abstraction for SaveChangesAsync. The DTO frequency types needed correction. These are small issues, but they remind you that you can’t just accept Claude’s output without reading it.

The total time from ticket to PR was about 45 minutes. Without Claude, this feature would take me roughly three hours. That’s not a 10x speedup — it’s a solid 4x on a realistic feature.


The prompt sequence

Here’s the exact sequence I used, in case you want to replicate this workflow:

  1. Context first: “Read the existing codebase structure — especially how other entities are modeled, how controllers are organized, and what patterns we use for validation and DTOs.”
  2. Plan: “Propose an implementation plan for this notification preferences feature.”
  3. Entity: “Create the NotificationPreference entity following the same patterns as UserProfile.”
  4. Migration: “Create the EF Core migration.”
  5. Endpoints: “Build the API endpoints. Follow our existing controller patterns. Include DTOs and FluentValidation.”
  6. Tests: “Write xUnit tests. Cover happy path, defaults creation, and validation errors.”
  7. Review: “Review the feature we just built. Check for missing validation, security issues, performance concerns, and pattern inconsistencies.”
  8. PR: “Create a feature branch, commit, and draft a PR description.”

The key insight: every prompt references the existing codebase. “Follow our patterns.” “Same as UserProfile.” “Match existing controllers.” This is how you get output that fits your project instead of generic Stack Overflow answers.


Try it yourself

Pick a ticket from your backlog. Something real, not a hello-world exercise. Walk through these eight prompts and see how far Claude gets before you need to intervene.

You’ll intervene. That’s the point. The value isn’t that Claude builds the entire feature without you — it’s that you spend your time on decisions instead of typing. And that’s a much better use of a developer’s afternoon.