1. Role of Catalog’s CourseRun and CatalogCourse Models#
Status#
Draft
Context#
openedx_catalog holds the core models that say which courses exist in an instance: CatalogCourse (a set of runs, e.g. “Math 100”) and CourseRun (one run, e.g. “Math 100 2026Fall”). openedx_content holds the authored, versioned material itself, grouped into LearningPackage instances. This ADR clarifies how the catalog models are meant to be used.
Until now the direction of the relationship between the two apps has been left open (“TBD” in the catalog architecture diagram), and two proposals have pulled in opposite directions:
The proposed Course Learning Packages ADR gives
CourseRuna foreign key toLearningPackage, which requires the catalog to import content.7. Pathways: Split Between Catalog and Content stated that
openedx_contentknows aboutopenedx_catalogand never the reverse, so that Pathway Items can reference course runs directly.
Meanwhile, ContentLibrary in openedx-platform already points at LearningPackage from the outside, and openedx_learning is already layered above openedx_content in .importlinter.
Separately, admins can now create catalog courses and course runs before any content exists, and the rest of the system needs a clear rule about what that implies.
Decisions#
1. The catalog layers above content#
openedx_catalog may import and hold foreign keys to openedx_content. But openedx_content must never import openedx_catalog.
openedx_learning (Pathways, Competency-Based Education, and more) sits above both.
The resulting order, enforced by the src_layering contract in .importlinter, is:
openedx_learning > openedx_catalog > openedx_content > openedx_tagging
The general rule behind this ordering is: a context model points at its content; content never points at contexts. openedx_content is generic infrastructure used by courses, libraries, pathways and future context types, and its applets are deliberately ignorant of what a learning package represents. A course run, a library, or a pathway is the thing that knows which package (or which container within a package) holds its content, in exactly the way ContentLibrary already does.
This does not contradict the intent of 7. Pathways: Split Between Catalog and Content, whose real requirements are that the versioned Pathway definition holds the references to the unversioned catalog objects. Those definition models live in openedx_learning, above the catalog, so they can reference CourseRun and content freely. Decision 4 of that ADR has been amended to name openedx_learning rather than openedx_content as the side that knows about the catalog.
2. Catalog entries may be placeholders with no content#
A CatalogCourse or CourseRun may exist with no content behind it: as a marketing or enrollment placeholder, as a planned future run, or because its content still lives in modulestore.
The converse guarantee does hold: if a course exists anywhere in the system, it exists as a CatalogCourse and CourseRun row.
3. Catalog models are the canonical target for course foreign keys#
For performance and correctness, any Django model in this repository or in openedx-platform that needs to reference a course should do so with a foreign key to CourseRun (or, rarely, CatalogCourse), rather than by storing a course key string or pointing at CourseOverview (although much existing code does not yet follow this new convention).
On the other hand, public APIs and events should continue to identify courses by their full string course key and never expose the integer primary keys.
Consequences#
Models related to pathway contents will not be added to
openedx_contentbut rather will live inopenedx_learning. This makes sense, as Pathway details are only useful for implementing Pathways, and are not a generic primitive likeComponentthat is used in multiple contexts.Every relationship from the catalog to content is nullable.
Code must never assume that content, a
CourseOverview, or any other related model exists just because a catalog row does. Content-dependent behavior must check for the relationship and degrade gracefully.openedx_contentneeds no course-, library- or pathway-aware code, and stays reusable by any context type.Looking up a run’s content is a direct key lookup from the catalog side. Looking up which course/library/etc. a package belongs to is a reverse query (potentially checking multiple tables, e.g. both
CourseRunandContentLibrary), which is acceptable because it is an uncommon use case.Deleting a learning package can never cascade into catalog entries, enrollments, or anything else that hangs off the catalog.
A
LearningPackagecan be created and populated without yet being associated with a course/library/etc.Import Linter will fail any change that makes
openedx_contentimportopenedx_catalog, including a Pathways applet that referencesCourseRunif it is placed insideopenedx_content. Such models belong inopenedx_learning.
Rejected Alternatives#
Peer layering with cross-references. In this case, we’d state that in general, LearningPackage is context agnostic, and catalog models point to LearningPackage rather than vice versa, but within openedx_content a new PathwayItem model allows references to CourseRun. This is probably workable, but lacks the clean separation that we’re looking for. It is also a package cycle: openedx_catalog imports openedx_content for LearningPackage while openedx_content imports openedx_catalog for CourseRun, which a layers contract in Import Linter cannot express at all. What’s more, PathwayItem is only useful for the pathways app, which is presumably optional, so it’s not as generic or reusable as the other models offered by openedx_content.
Content layers above the catalog. In this case, openedx_content would need to hold some mechanism for mapping from LearningPackage (or a root container) to CourseRun (and presumably to ContentLibrary), either hard-coding awareness of “courses”, “libraries” and “pathways”, or using a polymorphic context registry. This makes the generic content layer aware of one specific context type, and offers no way to treat libraries or pathways the same way without also moving them below content, which is impossible for ContentLibrary in openedx-platform.