Instagram Clone
A complete example of an Instagram clone with users, photos, likes, comments, and follow relationships using Dynatable and Single Table Design.
Entity-Relationship Diagram (ER)
Logical model. Physical partition/sort keys (e.g.
USER#${username},PHOTO#${photoId}) live in the Key Structure table below.
FOLLOW is the join entity for the N:M User ↔ User relationship: followedUsername and followingUsername are both PK+FK to USER. Its GSI1 swaps the two columns so you can query both "who follows X" (primary index) and "who X follows" (GSI1).
Single Table Design
Key Structure
| Entity Type | PK | SK | GSI1PK | GSI1SK |
|---|---|---|---|---|
| User | USER#{username} | USER#{username} | - | - |
| Photo | UP#{username} | PHOTO#{photoId} | - | - |
| Story | UP#{username} | STORY#{storyId} | - | - |
| Like | PL#{photoId} | LIKE#{likingUsername} | PL#{photoId} | LIKE#{likeId} |
| Comment | PC#{photoId} | COMMENT#{commentId} | - | - |
| Follow | FOLLOW#{followedUsername} | FOLLOW#{followingUsername} | FOLLOW#{followingUsername} | FOLLOW#{followedUsername} |
The GSI in this example is named gsi1, with column names GSI1PK and GSI1SK.
Access Patterns
-
User
- Get user by username:
GetItemwith PK=USER#{username}, SK=USER#{username} - Create unique user:
PutItemwithattribute_not_existscondition
- Get user by username:
-
Photos
- Create photo:
PutItemwith PK=UP#{username}, SK=PHOTO#{photoId} - Get photo:
GetItemwith PK=UP#{username}, SK=PHOTO#{photoId} - List user photos:
Querywith PK=UP#{username}, SKbegins_with"PHOTO#"
- Create photo:
-
Stories
- Create story:
PutItemwith PK=UP#{username}, SK=STORY#{storyId} - Get story:
GetItemwith PK=UP#{username}, SK=STORY#{storyId} - List user stories:
Querywith PK=UP#{username}, SKbegins_with"STORY#"
- Create story:
-
Likes
- Like a photo:
TransactWrite- Put Like + Update Photo.likesCount - Unlike photo:
TransactWrite- Delete Like + Update Photo.likesCount - List likes (chronological): Query gsi1 with GSI1PK=
PL#{photoId}(re-sorted bylikeIdULID) - Check if user liked:
GetItemwith PK=PL#{photoId}, SK=LIKE#{likingUsername}
- Like a photo:
-
Comments
- Comment on photo:
TransactWrite- Put Comment + Update Photo.commentCount - List comments:
Querywith PK=PC#{photoId}, SKbegins_with"COMMENT#" - Delete comment:
TransactWrite- Delete Comment + Update Photo.commentCount
- Comment on photo:
-
Follow
- Follow user:
TransactWrite- Put Follow + Update followerCount + Update followingCount - Unfollow user:
TransactWrite- Delete Follow + Update followerCount + Update followingCount - List followers:
Querywith PK=FOLLOW#{followedUsername}, thenBatchGetItemfor user details - List following: Query gsi1 with GSI1PK=
FOLLOW#{followingUsername}, thenBatchGetItemfor details
- Follow user:
Schema Definition
import { Table, type InferModelFromSchema, type ArrayItem } from '@ftschopp/dynatable-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const InstagramSchema = {
format: 'dynatable:1.0.0',
version: '1.0.0',
indexes: {
primary: { hash: 'PK', sort: 'SK' },
gsi1: { hash: 'GSI1PK', sort: 'GSI1SK' },
},
models: {
User: {
key: {
PK: { type: String, value: 'USER#${username}' },
SK: { type: String, value: 'USER#${username}' },
},
attributes: {
username: { type: String, required: true },
name: { type: String, required: true },
followerCount: { type: Number, default: 0 },
followingCount: { type: Number, default: 0 },
},
},
Photo: {
key: {
PK: { type: String, value: 'UP#${username}' },
SK: { type: String, value: 'PHOTO#${photoId}' },
},
attributes: {
username: { type: String, required: true },
photoId: { type: String, generate: 'ulid' },
url: { type: String, required: true },
caption: { type: String },
likesCount: { type: Number, default: 0 },
commentCount: { type: Number, default: 0 },
},
},
// Stories — demonstrates nested Object schema and typed Array items
Story: {
key: {
PK: { type: String, value: 'UP#${username}' },
SK: { type: String, value: 'STORY#${storyId}' },
},
attributes: {
username: { type: String, required: true },
storyId: { type: String, generate: 'ulid' },
viewCount: { type: Number, default: 0 },
// Typed array of frame objects
frames: {
type: Array,
default: [],
items: {
type: Object,
schema: {
url: { type: String, required: true },
duration: { type: Number },
mediaType: { type: String },
},
},
},
// Typed nested object
location: {
type: Object,
schema: {
city: { type: String },
country: { type: String },
lat: { type: Number },
lng: { type: Number },
},
},
},
},
Like: {
key: {
PK: { type: String, value: 'PL#${photoId}' },
SK: { type: String, value: 'LIKE#${likingUsername}' },
},
index: {
GSI1PK: { type: String, value: 'PL#${photoId}' },
GSI1SK: { type: String, value: 'LIKE#${likeId}' },
},
attributes: {
photoId: { type: String, required: true },
likingUsername: { type: String, required: true },
likeId: { type: String, generate: 'ulid', required: true },
},
},
Comment: {
key: {
PK: { type: String, value: 'PC#${photoId}' },
SK: { type: String, value: 'COMMENT#${commentId}' },
},
attributes: {
photoId: { type: String, required: true },
commentId: { type: String, generate: 'ulid', required: true },
commentingUsername: { type: String, required: true },
content: { type: String, required: true },
},
},
Follow: {
key: {
PK: { type: String, value: 'FOLLOW#${followedUsername}' },
SK: { type: String, value: 'FOLLOW#${followingUsername}' },
},
index: {
GSI1PK: { type: String, value: 'FOLLOW#${followingUsername}' },
GSI1SK: { type: String, value: 'FOLLOW#${followedUsername}' },
},
attributes: {
followedUsername: { type: String, required: true },
followingUsername: { type: String, required: true },
},
},
},
params: {
timestamps: true,
cleanInternalKeys: true, // strip PK/SK/_type from returned items
},
} as const;
export const table = new Table({
name: 'InstagramClone',
client: new DynamoDBClient({ region: 'us-east-1' }),
schema: InstagramSchema,
});
// Entity types inferred from the schema
type StoryEntity = InferModelFromSchema<typeof InstagramSchema, 'Story'>;
// Extract the frame item type using ArrayItem
type StoryFrame = ArrayItem<StoryEntity['frames']>;
// → { url: string; duration?: number; mediaType?: string }
User Operations
Create User
async function createUser(username: string, name: string) {
return await table.entities.User.put({
username,
name,
})
.ifNotExists()
.execute();
}
// Usage
await createUser('juanca', 'Juan Carlos Bondi');
Get User Profile
async function getUserProfile(username: string) {
return await table.entities.User.get({
username,
}).execute();
}
// Usage
const user = await getUserProfile('juanca');
console.log(user?.name, user?.followerCount); // user is User | undefined
Update User Profile
async function updateUserProfile(username: string, updates: { name?: string }) {
// DynamoDB rejects an UpdateItem with no UpdateExpression, so bail out
// when there's nothing to update.
if (!updates.name) return;
return await table.entities.User
.update({ username })
.set('name', updates.name)
.returning('ALL_NEW')
.execute();
}
// Usage
await updateUserProfile('juanca', {
name: 'Juan Carlos Bondi - Photographer',
});
Photo Operations
Create Photo
async function createPhoto(username: string, url: string, caption?: string) {
return await table.entities.Photo.put({
username,
url,
caption,
}).execute();
}
// Usage
const photo = await createPhoto(
'juanca',
'https://photos.app.goo.gl/abcd1234',
'Sunset at the beach!'
);
console.log(photo.photoId); // Auto-generated ULID
Get Photo
async function getPhoto(username: string, photoId: string) {
return await table.entities.Photo.get({
username,
photoId,
}).execute();
}
// Usage
const photo = await getPhoto('juanca', '01K16ZP43BRX67DG50SHGZ11DS');
console.log(photo?.url, photo?.likesCount); // photo is Photo | undefined
List User Photos
async function getUserPhotos(username: string, limit: number = 20) {
return await table.entities.Photo.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(limit)
.scanIndexForward(false) // Most recent first
.execute();
}
// Usage
const photos = await getUserPhotos('juanca', 12);
photos.forEach((photo) => {
console.log(photo.url, photo.caption, photo.createdAt);
});
List Popular User Photos
likesCount > minLikes is not part of the key — DynamoDB applies it as a FilterExpression after reading every photo in the partition. RCUs are billed on the rows read, not the rows returned. For users with many photos you'll want to denormalize popular photos into a separate GSI or item collection.
async function getPopularUserPhotos(username: string, minLikes: number = 10) {
return await table.entities.Photo.query()
.where((attr, op) => op.and(op.eq(attr.username, username), op.gt(attr.likesCount, minLikes)))
.scanIndexForward(false)
.execute();
}
// Usage
const popularPhotos = await getPopularUserPhotos('juanca', 50);
Delete Photo
async function deletePhoto(username: string, photoId: string) {
return await table.entities.Photo.delete({
username,
photoId,
}).execute();
}
Like Operations
Like a Photo
async function likePhoto(photoId: string, photoOwner: string, likingUsername: string) {
// Use transaction for atomicity
return await table
.transactWrite()
.addPut(
table.entities.Like.put({
photoId,
likingUsername,
})
.ifNotExists()
.dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('likesCount', 1)
.dbParams()
)
.execute();
}
// Usage
await likePhoto('photo123', 'juanca', 'alice');
Unlike a Photo
async function unlikePhoto(photoId: string, photoOwner: string, likingUsername: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Like.delete({
photoId,
likingUsername,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('likesCount', -1)
.dbParams()
)
.execute();
}
// Usage
await unlikePhoto('photo123', 'juanca', 'alice');
Check if User Liked Photo
async function hasUserLikedPhoto(photoId: string, username: string): Promise<boolean> {
// .get() returns undefined when the item doesn't exist (it does not throw),
// so a !!like check is enough — wrapping in try/catch would swallow real errors.
const like = await table.entities.Like.get({
photoId,
likingUsername: username,
}).execute();
return !!like;
}
// Usage
const hasLiked = await hasUserLikedPhoto('photo123', 'alice');
console.log(hasLiked ? 'Already liked' : 'Not liked yet');
List Users Who Liked (Chronological)
Like reuses its primary PK as GSI1PK, but swaps the SK from likingUsername to a generated likeId (ULID). That re-sort gives a chronological view of the likes — querying the primary index would order them alphabetically by likingUsername instead.
async function getPhotoLikers(photoId: string, limit: number = 50) {
// Use GSI1 to sort by likeId (ULID = timestamp)
const likes = await table.entities.Like.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.useIndex('gsi1')
.limit(limit)
.scanIndexForward(true) // Oldest first
.execute();
if (likes.length === 0) return [];
return await table.entities.User.batchGet(
likes.map((like) => ({ username: like.likingUsername }))
).execute();
}
// Usage
const likers = await getPhotoLikers('photo123', 20);
likers.forEach((user) => {
console.log(user.username, user.name);
});
Comment Operations
Comment on Photo
async function commentOnPhoto(
photoId: string,
photoOwner: string,
commentingUsername: string,
content: string
) {
return await table
.transactWrite()
.addPut(
table.entities.Comment.put({
photoId,
commentingUsername,
content,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('commentCount', 1)
.dbParams()
)
.execute();
}
// Usage
await commentOnPhoto('photo123', 'juanca', 'alice', 'Amazing photo! Love the colors!');
List Photo Comments
async function getPhotoComments(photoId: string, limit: number = 50) {
const comments = await table.entities.Comment.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.limit(limit)
.scanIndexForward(true) // Oldest first
.execute();
// Optionally get user details
if (comments.length > 0) {
const users = await table.entities.User.batchGet(
comments.map((c) => ({ username: c.commentingUsername }))
).execute();
// Combine comments with user data
return comments.map((comment) => ({
...comment,
user: users.find((u) => u.username === comment.commentingUsername),
}));
}
return [];
}
// Usage
const comments = await getPhotoComments('photo123', 20);
comments.forEach((comment) => {
console.log(`${comment.user?.name}: ${comment.content}`);
});
Delete Comment
async function deleteComment(photoId: string, commentId: string, photoOwner: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Comment.delete({
photoId,
commentId,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('commentCount', -1)
.dbParams()
)
.execute();
}
Update Comment
On update() builders, .where(...) builds a ConditionExpression — the update only happens if the condition is satisfied (here: only the original author can edit). On query() builders, .where(...) defines key conditions and filters. Same name, different role.
async function updateComment(
photoId: string,
commentId: string,
commentingUsername: string,
newContent: string
) {
return await table.entities.Comment.update({
photoId,
commentId,
})
.set('content', newContent)
.where((attr, op) => op.eq(attr.commentingUsername, commentingUsername))
.returning('ALL_NEW')
.execute();
}
// Usage
await updateComment(
'photo123',
'comment456',
'alice',
'Updated: Amazing photo! Love the composition!'
);
Follow Operations
Follow User
async function followUser(followingUsername: string, followedUsername: string) {
return await table
.transactWrite()
.addPut(
table.entities.Follow.put({
followingUsername,
followedUsername,
})
.ifNotExists()
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followedUsername,
})
.add('followerCount', 1)
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followingUsername,
})
.add('followingCount', 1)
.dbParams()
)
.execute();
}
// Usage: Alice follows Juan Carlos
await followUser('alice', 'juanca');
Unfollow User
async function unfollowUser(followingUsername: string, followedUsername: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Follow.delete({
followingUsername,
followedUsername,
}).dbParams()
)
.addUpdate(
table.entities.User.update({
username: followedUsername,
})
.add('followerCount', -1)
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followingUsername,
})
.add('followingCount', -1)
.dbParams()
)
.execute();
}
// Usage
await unfollowUser('alice', 'juanca');
List User Followers
async function getFollowers(username: string, limit: number = 50) {
// Query to get follow relationships
const follows = await table.entities.Follow.query()
.where((attr, op) => op.eq(attr.followedUsername, username))
.limit(limit)
.execute();
if (follows.length === 0) return [];
return await table.entities.User.batchGet(
follows.map((f) => ({ username: f.followingUsername }))
).execute();
}
// Usage
const followers = await getFollowers('juanca', 20);
console.log(`${followers.length} followers`);
followers.forEach((user) => {
console.log(user.username, user.name);
});
List Following Users
async function getFollowing(username: string, limit: number = 50) {
// Query on GSI1 to get followed users
const follows = await table.entities.Follow.query()
.where((attr, op) => op.eq(attr.followingUsername, username))
.useIndex('gsi1')
.limit(limit)
.execute();
if (follows.length === 0) return [];
return await table.entities.User.batchGet(
follows.map((f) => ({ username: f.followedUsername }))
).execute();
}
// Usage
const following = await getFollowing('alice', 20);
console.log(`Following ${following.length} users`);
Check if User Follows Another
async function isFollowing(followingUsername: string, followedUsername: string): Promise<boolean> {
// .get() returns undefined when the relation doesn't exist; no try/catch needed.
const follow = await table.entities.Follow.get({
followingUsername,
followedUsername,
}).execute();
return !!follow;
}
// Usage
const following = await isFollowing('alice', 'juanca');
console.log(following ? 'Following' : 'Not following');
Story Operations
Create a Story
async function createStory(username: string, frames: StoryFrame[], city?: string) {
return await table.entities.Story.put({
username,
frames,
...(city ? { location: { city } } : {}),
}).execute();
}
// Usage
const story = await createStory('juanca', [
{ url: 'https://cdn.example.com/clip1.mp4', duration: 5, mediaType: 'video' },
{ url: 'https://cdn.example.com/img1.jpg', duration: 3, mediaType: 'image' },
]);
console.log(story.storyId); // Auto-generated ULID
console.log(story.frames[0]?.url); // Fully typed; frames[] could be empty
List User Stories
async function getUserStories(username: string) {
return await table.entities.Story.query()
.where((attr, op) => op.eq(attr.username, username))
.scanIndexForward(false) // Most recent first
.execute();
}
// Usage
const stories = await getUserStories('juanca');
stories.forEach((story) => {
console.log(`Story ${story.storyId}: ${story.frames.length} frame(s)`);
});
Increment View Count
async function incrementStoryViews(username: string, storyId: string) {
return await table.entities.Story.update({ username, storyId })
.add('viewCount', 1)
.returning('ALL_NEW')
.execute();
}
Complete Workflows
User Feed (Photos from Followed Users)
async function getUserFeed(username: string, limit: number = 30) {
// 1. Get followed users
const following = await getFollowing(username, 100);
if (following.length === 0) return [];
// 2. Get photos from each followed user
const photoPromises = following.map((user) => getUserPhotos(user.username, 10));
const photosByUser = await Promise.all(photoPromises);
// 3. Combine and sort by date (toSorted returns a fresh array, leaving inputs untouched)
const allPhotos = photosByUser
.flat()
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// 4. Return first N photos
return allPhotos.slice(0, limit);
}
// Usage
const feed = await getUserFeed('alice', 20);
feed.forEach((photo) => {
console.log(`${photo.username}: ${photo.caption}`);
});
Get Photo with Full Details
async function getPhotoWithDetails(username: string, photoId: string) {
const [photo, comments, likers] = await Promise.all([
getPhoto(username, photoId),
getPhotoComments(photoId, 20),
getPhotoLikers(photoId, 20),
]);
if (!photo) return undefined;
return {
...photo,
comments: comments.slice(0, 3), // First 3 comments
commentCount: photo.commentCount,
recentLikers: likers.slice(0, 5), // First 5 who liked
totalLikes: photo.likesCount,
};
}
// Usage
const photoDetails = await getPhotoWithDetails('juanca', 'photo123');
if (photoDetails) {
console.log(photoDetails.caption);
console.log(`${photoDetails.totalLikes} likes, ${photoDetails.commentCount} comments`);
}
Complete User Profile
async function getCompleteUserProfile(username: string) {
const [user, photos, followers, following] = await Promise.all([
getUserProfile(username),
getUserPhotos(username, 12), // Last 12 photos
getFollowers(username, 100),
getFollowing(username, 100),
]);
if (!user) return undefined;
return {
...user,
photos,
photoCount: photos.length,
followers,
following,
};
}
// Usage
const profile = await getCompleteUserProfile('juanca');
if (profile) {
console.log(`${profile.name} (@${profile.username})`);
console.log(`${profile.followerCount} followers, ${profile.followingCount} following`);
console.log(`${profile.photoCount} photos`);
}
Search User Photos
async function searchUserPhotos(username: string, minLikes?: number, minComments?: number) {
return await table.entities.Photo.query()
.where((attr, op) => {
const conditions = [
op.eq(attr.username, username),
...(minLikes !== undefined ? [op.gt(attr.likesCount, minLikes)] : []),
...(minComments !== undefined ? [op.gt(attr.commentCount, minComments)] : []),
];
return conditions.length > 1 ? op.and(...conditions) : conditions[0]!;
})
.scanIndexForward(false)
.execute();
}
// Usage: Search photos with more than 100 likes
const popularPhotos = await searchUserPhotos('juanca', 100);
Pagination
User Photos with Pagination
async function getUserPhotosPaginated(username: string, limit: number = 20, lastKey?: any) {
const base = table.entities.Photo.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(limit)
.scanIndexForward(false);
return await (lastKey ? base.startFrom(lastKey) : base).executeWithPagination();
}
// Usage - first page
const page1 = await getUserPhotosPaginated('juanca', 12);
console.log(page1.items);
// Next page
if (page1.lastEvaluatedKey) {
const page2 = await getUserPhotosPaginated('juanca', 12, page1.lastEvaluatedKey);
console.log(page2.items);
}
Comments with Pagination
async function getPhotoCommentsPaginated(photoId: string, limit: number = 20, lastKey?: any) {
const base = table.entities.Comment.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.limit(limit)
.scanIndexForward(true);
const result = await (lastKey ? base.startFrom(lastKey) : base).executeWithPagination();
// Get user details for this page
if (result.items.length > 0) {
const users = await table.entities.User.batchGet(
result.items.map((c) => ({ username: c.commentingUsername }))
).execute();
const commentsWithUsers = result.items.map((comment) => ({
...comment,
user: users.find((u) => u.username === comment.commentingUsername),
}));
return {
items: commentsWithUsers,
lastEvaluatedKey: result.lastEvaluatedKey,
};
}
return result;
}
Error Handling
async function likePhotoSafely(photoId: string, photoOwner: string, likingUsername: string) {
try {
await likePhoto(photoId, photoOwner, likingUsername);
return { success: true };
} catch (error: any) {
if (error.name === 'TransactionCanceledException') {
// Already liked before (AWS SDK v3 surfaces the error class via `name`)
return {
success: false,
error: 'Already liked this photo',
};
}
console.error('Failed to like photo:', error);
return {
success: false,
error: error.message,
};
}
}
async function createPhotoSafely(username: string, url: string, caption?: string) {
try {
const photo = await createPhoto(username, url, caption);
return { success: true, photo };
} catch (error: any) {
console.error('Failed to create photo:', error);
return {
success: false,
error: error.message,
};
}
}
Testing
import { describe, it, expect, beforeAll } from '@jest/globals';
describe('Instagram Clone', () => {
beforeAll(async () => {
// Setup test data
await createUser('testuser1', 'Test User 1');
await createUser('testuser2', 'Test User 2');
});
it('should create a photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo.jpg', 'Test caption');
expect(photo.username).toBe('testuser1');
expect(photo.photoId).toBeDefined();
expect(photo.caption).toBe('Test caption');
});
it('should allow user to follow another user', async () => {
await followUser('testuser2', 'testuser1');
const user1 = await getUserProfile('testuser1');
const user2 = await getUserProfile('testuser2');
expect(user1?.followerCount).toBe(1);
expect(user2?.followingCount).toBe(1);
});
it('should like a photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo2.jpg');
await likePhoto(photo.photoId, 'testuser1', 'testuser2');
const updatedPhoto = await getPhoto('testuser1', photo.photoId);
expect(updatedPhoto?.likesCount).toBe(1);
});
it('should add comment to photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo3.jpg');
await commentOnPhoto(photo.photoId, 'testuser1', 'testuser2', 'Great photo!');
const comments = await getPhotoComments(photo.photoId);
expect(comments.length).toBe(1);
expect(comments[0].content).toBe('Great photo!');
});
});
Features Demonstrated
This example demonstrates:
- ✅ Single Table Design - All entities in one table
- ✅ 1:N Relationships - User → Photos, Photo → Comments
- ✅ N:M Relationships - Users ↔ Followers (Follow)
- ✅ GSI Usage - Alternative access patterns
- ✅ Atomic Transactions - Like, Follow with counters
- ✅ Auto-generated IDs - ULID for photos, likes, comments
- ✅ Automatic Timestamps - createdAt, updatedAt
- ✅ Pagination - For long lists
- ✅ BatchGet - Efficiently fetch multiple users
- ✅ Type Safety - TypeScript end-to-end
- ✅ Conditions - ifNotExists, where conditions
- ✅ Atomic Counters - followerCount, likesCount, commentCount
- ✅ Nested Objects - Typed
locationobject withschema - ✅ Typed Arrays -
framesarray with typed item schema - ✅ ArrayItem utility - Extract frame item type with
ArrayItem<T>
Single Table Design Benefits
- Performance: Fewer round-trips to the database
- Transactions: Atomic operations between related entities
- Cost: Lower RCU/WCU consumption
- Scalability: Uniform load distribution
- Maintenance: Single table to manage
Next Steps
To enhance this example, consider:
- Add story expiration (TTL attribute)
- Add Direct Messages between users
- Implement hashtags for photos
- Add notifications
- Implement user search
- Add saved posts (bookmarked photos)