11 — CRUD, validation, and error contracts

Goal

Create tasks from Flutter and learn how application failures become stable gRPC errors.

Step 1: Add create logic

This chapter turns the read-only demo into a small writeable service. We use a mutex-protected map to focus on RPC behavior before introducing SQL. A mutex allows many readers or one writer at a time, which matters because gRPC may handle several requests concurrently.

Replace server/internal/rpc/task_server.go with the following in-memory version:

package rpc

import (
    "context"
    "strings"
    "sync"
    "time"

    fieldnotesv1 "github.com/example/field-notes/gen/go/fieldnotes/v1"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "google.golang.org/protobuf/types/known/timestamppb"
)

type TaskServer struct {
    fieldnotesv1.UnimplementedTaskServiceServer
    mu sync.RWMutex
    items map[string]*fieldnotesv1.Task
}

func NewTaskServer() *TaskServer {
    now := timestamppb.Now()
    return &TaskServer{items: map[string]*fieldnotesv1.Task{
        "demo-task": {Id: "demo-task", ProjectId: "demo-project", Title: "Try the Flutter client", Status: fieldnotesv1.TaskStatus_TASK_STATUS_TODO, CreatedAt: now, UpdatedAt: now},
    }}
}

func (s *TaskServer) GetTask(ctx context.Context, req *fieldnotesv1.GetTaskRequest) (*fieldnotesv1.Task, error) {
    if strings.TrimSpace(req.GetId()) == "" { return nil, status.Error(codes.InvalidArgument, "task id is required") }
    s.mu.RLock()
    task, ok := s.items[req.GetId()]
    s.mu.RUnlock()
    if !ok { return nil, status.Error(codes.NotFound, "task not found") }
    return task, nil
}

func (s *TaskServer) CreateTask(ctx context.Context, req *fieldnotesv1.CreateTaskRequest) (*fieldnotesv1.CreateTaskResponse, error) {
    projectID := strings.TrimSpace(req.GetProjectId())
    title := strings.TrimSpace(req.GetTitle())
    if projectID == "" { return nil, status.Error(codes.InvalidArgument, "project_id is required") }
    if title == "" { return nil, status.Error(codes.InvalidArgument, "title is required") }
    if len([]rune(title)) > 120 { return nil, status.Error(codes.InvalidArgument, "title must be 120 characters or fewer") }
    if err := ctx.Err(); err != nil { return nil, status.FromContextError(err).Err() }
    now := timestamppb.New(time.Now().UTC())
    task := &fieldnotesv1.Task{Id: "task-" + now.AsTime().Format("20060102150405.000000000"), ProjectId: projectID, Title: title, Description: req.GetDescription(), Status: fieldnotesv1.TaskStatus_TASK_STATUS_TODO, CreatedAt: now, UpdatedAt: now}
    s.mu.Lock()
    s.items[task.Id] = task
    s.mu.Unlock()
    return &fieldnotesv1.CreateTaskResponse{Task: task}, nil
}

This version is for learning only: the map loses data on restart and the generated ID strategy is not production-grade. Later chapters replace it with a database and proper ID generation.

Validation happens before data changes. Trimming whitespace makes a title containing only spaces count as empty. Counting Unicode runes applies the limit to characters rather than UTF-8 bytes. The lock only covers the map operation; validation and timestamp creation happen outside it so unrelated calls are not blocked unnecessarily.

Step 2: Test the API boundary

Regenerate code if the proto changed, format and run the server:

gofmt -w server/internal/rpc/task_server.go
go run ./server/cmd/api

Create a task with grpcurl from a second terminal:

grpcurl -plaintext -import-path api -proto fieldnotes/v1/task.proto -d '{"projectId":"demo-project","title":"Write the first task"}' localhost:50051 fieldnotes.v1.TaskService/CreateTask

Notice grpcurl uses protobuf JSON camelCase for project_id. Omit title to see InvalidArgument.

Step 3: Add a create form in Flutter

In TaskRepository, add:

Future<Task> createTask(String title) async {
  final response = await _client.createTask(CreateTaskRequest()
    ..projectId = 'demo-project'
    ..title = title);
  return response.task;
}

Add a TextEditingController, a text field, and a save button to the screen. Call createTask, show the returned task, and keep the text if the call fails. Dispose the controller in the state object's dispose method.

Step 4: Understand error mapping

The Go domain should return meaningful errors such as ErrNotFound or ErrInvalidTitle. The gRPC handler translates them to status codes. Use InvalidArgument for invalid caller input, NotFound for missing records, Unauthenticated when identity is missing, and PermissionDenied when identity lacks access. Avoid returning raw SQL errors or stack traces.

Status codes are part of the API's behavior. Flutter can tell Unauthenticated means credentials need attention, while Unavailable may be retryable. The message is for a person; the code is for program logic. Keep messages useful but do not include confidential internals.

Checkpoint

Create a task from Flutter, then fetch it by its returned ID using grpcurl. Confirm a blank title returns an error and does not create a task.

Practice

Add an UpdateTask RPC with explicit fields or a protobuf FieldMask. Specify what happens when the client omits the description: preserve it, clear it, or reject the update. Document the choice.

results matching ""

    No results matching ""