Von unten nach oben
Präsentation
Die Oberfläche, die die API konsumiert und Daten darstellt.
API
Der Vertrag nach außen – welche Endpunkte gibt es, welche Daten fließen?
Geschäftslogik
Regeln und Abläufe der Fachdomäne – unabhängig von Datenbank und HTTP.
Datenzugriff
Kapselt SQL hinter einer Schnittstelle, damit die Logik nicht weiß, wie gespeichert wird.
PersistenzschichtFundament
Welche Daten gibt es, wie hängen sie zusammen? Tabellen, Schlüssel, Constraints, Indizes.
Warum die Persistenz zuerst?
Daten überleben Code
Frontends werden neu geschrieben, Frameworks wechseln – die Daten bleiben oft jahrzehntelang.
Änderungen sind teuer
Ein Feld umbenennen heißt bei Live-Daten: Migration, Rückwärtskompatibilität, Downtime-Risiko.
Fundament für alles
Wer Entitäten und Beziehungen kennt, kann Logik und API sauber darauf abbilden.
Welche Datenbank wann passt und was alles zur Persistenzschicht gehört, erklärt das Unterthema Persistenzschicht und DatenbankwahlThema · Persistenzschicht und DatenbankwahlDie Persistenzschicht speichert Daten dauerhaft – mit Schema, Schlüsseln, Constraints, Indizes und Migrationen in einem Datenspeicher, der zu den Zugriffsmustern passt..
Ein Beispiel durch alle Schichten
Eine Aufgabenverwaltung mit PostgreSQL, Python (SQLAlchemy + FastAPI) und Angular. Ein User hat viele Tasks, Tasks und Tags stehen in einer n:m-Beziehung.
1 · Persistenz
PostgreSQL-Schema: Schlüssel, Constraints und ein Index für die häufigste Abfrage „offene Tasks eines Users nach Fälligkeit“.
CREATE TABLE app_user (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE task (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app_user (id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
due_date DATE,
done BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_task_user_due ON task (user_id, due_date);
CREATE TABLE tag (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE task_tag (
task_id BIGINT REFERENCES task (id) ON DELETE CASCADE,
tag_id BIGINT REFERENCES tag (id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);Spätere Änderungen laufen als Migration (hier Alembic), nie per Hand:
def upgrade() -> None:
op.add_column("task", sa.Column("priority", sa.SmallInteger(), nullable=False, server_default="2"))
def downgrade() -> None:
op.drop_column("task", "priority")2 · Datenzugriff
Entity + Repository: Das Mapping beschreibt die Tabelle im Code, das Repository kapselt die Abfragen. Dieselbe Schicht in drei Sprachen:
Python · SQLAlchemy
class Task(Base):
__tablename__ = "task"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("app_user.id"))
title: Mapped[str] = mapped_column(String(200))
due_date: Mapped[date | None]
done: Mapped[bool] = mapped_column(default=False)
class TaskRepository:
def __init__(self, session: Session) -> None:
self.session = session
def open_tasks_for(self, user_id: int) -> list[Task]:
stmt = (
select(Task)
.where(Task.user_id == user_id, Task.done.is_(False))
.order_by(Task.due_date)
)
return list(self.session.scalars(stmt))
def add(self, task: Task) -> Task:
self.session.add(task)
self.session.flush() # vergibt die ID
return taskJava · Spring Data JPA
@Entity
@Table(name = "task")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private AppUser user;
private String title;
private LocalDate dueDate; // → Spalte due_date
private boolean done;
}
public interface TaskRepository extends JpaRepository<Task, Long> {
// Spring leitet die SQL-Abfrage aus dem Methodennamen ab
List<Task> findByUserIdAndDoneFalseOrderByDueDateAsc(Long userId);
}C# · EF Core
// "TaskItem", weil Task in .NET schon System.Threading.Tasks.Task ist
public class TaskItem
{
public long Id { get; set; }
public long UserId { get; set; }
public string Title { get; set; } = "";
public DateOnly? DueDate { get; set; }
public bool Done { get; set; }
}
public class TaskRepository(AppDbContext db)
{
public Task<List<TaskItem>> OpenTasksFor(long userId) =>
db.Tasks
.Where(t => t.UserId == userId && !t.Done)
.OrderBy(t => t.DueDate)
.ToListAsync();
}3 · Logik
Service: Hier stehen die Fachregeln – ohne SQL, ohne HTTP. Deshalb lässt er sich mit einem Fake-Repository in Millisekunden testen.
class TaskService:
def __init__(self, repo: TaskRepository) -> None:
self.repo = repo
def create(self, user_id: int, title: str, due: date | None) -> Task:
if due is not None and due < date.today():
raise ValueError("Fälligkeitsdatum liegt in der Vergangenheit")
task = Task(user_id=user_id, title=title.strip(), due_date=due)
return self.repo.add(task)4 · API
FastAPI-Endpunkt: Die DTOs (Pydantic) definieren den Vertrag nach außen. Daraus entsteht automatisch die OpenAPI-Spec unter /docs.
class TaskIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
due_date: date | None = None
class TaskOut(TaskIn):
model_config = ConfigDict(from_attributes=True)
id: int
done: bool
@router.post("/tasks", status_code=201, response_model=TaskOut)
def create_task(body: TaskIn, user: CurrentUser, service: TaskServiceDep) -> Task:
# CurrentUser und TaskServiceDep sind FastAPI-Dependencies (Auth bzw. Service mit Session)
return service.create(user.id, body.title, body.due_date)5 · Präsentation
Angular: Das Interface spiegelt TaskOut, ein Service kapselt den HTTP-Aufruf, die Komponente kennt nur den Service.
export interface Task {
id: number;
title: string;
due_date: string | null; // ISO-Datum, z. B. "2026-11-30"
done: boolean;
}
@Injectable({ providedIn: 'root' })
export class TaskApi {
private readonly http = inject(HttpClient);
create(title: string, due_date: string | null) {
return this.http.post<Task>('/api/tasks', { title, due_date });
}
}