Now let’s move to the next step: make the Message model production-friendly.
We’ll start with the most important field: role.
Step 3 – Design Message.role
Currently our database allows:
role = anything
For example:
"user""assistant""system""foo""hello""something-invalid"
That’s not what we want.
Our AI application has a defined set of roles:
userassistantsystem
Later, when we introduce tool calling, we may also need to represent tool messages depending on the provider/API design. But for our current application, we’ll keep the persisted roles to these three.
Why use a string instead of an integer?
You may remember our previous discussion about Rails enums.
We could store:
0 = user1 = assistant2 = system
But for an AI application, I prefer a string-backed enum.
Database:
role---------userassistantsystem
instead of:
role---------012
Why?
1. Database is self-describing
When you run:
SELECT role FROM messages;
you immediately see:
userassistantassistantusersystem
2. Easier debugging
When you’re debugging an AI conversation, the actual value is obvious.
3. Safer for external APIs
LLM APIs already use strings such as:
{ "role": "user"}
So our database representation matches the domain.
Step 3A – Add the Rails enum
Open:
app/models/message.rb
Currently you should have something like:
class Message < ApplicationRecord belongs_to :conversationend
Change it to:
class Message < ApplicationRecord belongs_to :conversation enum :role, { user: "user", assistant: "assistant", system: "system" }, validate: trueend
Understand this carefully
This:
enum :role, { user: "user", assistant: "assistant", system: "system"}, validate: true
doesn’t mean PostgreSQL has an enum type. We’re using a Rails enum backed by a string column.
PostgreSQL still has:
role character varying
Rails gives us a domain API on top of it.
Step 3B – Test the enum
Start Rails console:
bin/rails console
Find our message:
message = Message.first
Check:
message.role
You should get:
"user"
Now:
message.user?
Expected:
true
And:
message.assistant?
Expected:
false
Step 3C – Test the scopes
Rails also gives us useful scopes.
Try:
Message.user
and:
Message.assistant
and:
Message.system
For example:
Message.user
roughly translates to:
SELECT *FROM messagesWHERE role = 'user';
This is one of the benefits of using an enum.
Step 3D – Test invalid values
Now try:
Message.new( conversation: Conversation.first, role: "something_else", content: "test")
Because we specified:
validate: true
Rails should treat the role as invalid.
Check:
message = Message.new( conversation: Conversation.first, role: "something_else", content: "test")message.valid?
Expected:
false
Then:
message.errors.full_messages
You should see an error indicating that the role is not included in the allowed values.
Why validate: true?
This is worth understanding: Without validation, Rails enum behavior can raise an ArgumentError when assigning an invalid value.
With:
validate: true
we get normal ActiveRecord validation behavior:
message.valid?→ false
and:
message.errors
contains the validation error.
That’s generally more convenient when the model is receiving user/application input.
Step 3E – One more important layer: Database constraint
There is a subtle issue here.
Rails validation protects you when data enters through Rails.
But PostgreSQL doesn’t know that only these values are valid:
userassistantsystem
Someone could execute:
INSERT INTO messages (conversation_id, role, content)VALUES (1, 'invalid', '...');
directly against PostgreSQL.
The database would currently allow it.
This leads to an important senior-engineering principle:
Application-level validation and database-level integrity are complementary.
We’ll add a database constraint.
But don’t do that yet. First make sure the Rails enum works.
After that, we’re finally ready for the exciting part:
Rails ↓Ai::Client ↓LLM API ↓Real AI response
Now let’s strengthen the model at the database level.
You currently have Rails validation:
enum :role, { user: "user", assistant: "assistant", system: "system"}, validate: true
That’s good, but a senior Rails application shouldn’t rely only on model validation for important data integrity.
Step 4 – Add Database Constraints
We want PostgreSQL itself to enforce:
role MUST be: user assistant system
and:
content MUST NOT be NULLrole MUST NOT be NULL
This gives us two layers:
Rails ↓Model validationPostgreSQL ↓Database constraint
4.1 Why NULL matters
Currently this is possible at the database level:
role = NULL
But an AI message without a role doesn’t make sense.
Likewise:
content = NULL
doesn’t represent a meaningful message.
So we’ll make both required.
4.2 Create a new migration
Don’t modify the old migration because it has already been executed and committed.
Generate a new migration:
bin/rails generate migration AddMessageConstraints
Rails should create:
db/migrate/XXXXXXXXXXXXXX_add_message_constraints.rb
Open that file.
4.3 Add NOT NULL constraints
Put this inside change:
class AddMessageConstraints < ActiveRecord::Migration[8.1] def change change_column_null :messages, :role, false change_column_null :messages, :content, false endend
So conceptually:
def change change_column_null :messages, :role, false change_column_null :messages, :content, falseend
4.4 Add PostgreSQL CHECK constraint
Now we want PostgreSQL to enforce:
role IN ('user', 'assistant', 'system')
Add:
add_check_constraint( :messages, "role IN ('user', 'assistant', 'system')", name: "messages_role_check")
Our migration becomes:
class AddMessageConstraints < ActiveRecord::Migration[8.1] def change change_column_null :messages, :role, false change_column_null :messages, :content, false add_check_constraint( :messages, "role IN ('user', 'assistant', 'system')", name: "messages_role_check" ) endend
4.5 Run the migration
Execute:
bin/rails db:migrate
You should see Rails successfully applying the migration.
4.6 Inspect PostgreSQL
This is worth doing because understand what’s actually happening underneath Rails.
Run:
bin/rails dbconsole
Then:
\d messages
Look toward the bottom.
You should see a check constraint similar to:
messages_role_checkCHECK ((role)::text = ANY (...))
The exact display can vary by PostgreSQL version.
Also check:
\d+ messages
4.7 Test the database constraint
Now let’s prove that PostgreSQL protects us even if Rails is bypassed.
Inside psql, try:
INSERT INTO messages (conversation_id, role, content, created_at, updated_at)VALUES (1, 'invalid', 'This should fail', NOW(), NOW());
You should get an error similar to:
ERROR: new row for relation "messages" violates check constraint "messages_role_check"
That’s exactly what we want.
The database is now protecting the data.
Why is this important?
Suppose an int. asks:
“Why do you have both Rails validation and a PostgreSQL constraint?”
A strong senior-level answer would be:
“Rails validations provide application-level feedback and are useful for normal model operations, but they’re not a database integrity guarantee because data can enter through other paths. For important invariants such as message roles, I also enforce the constraint at the PostgreSQL level.”
That’s a much stronger answer than:
“Because Rails has validations.”
4.8 One more design question: content
We’re making:
change_column_null :messages, :content, false
But should an AI message be allowed to contain an empty string?
For example:
content: ""
NOT NULL allows that.
So:
NULL NO"" technically allowed"Hello" YES
Whether empty content should be allowed is an application-level business rule.
We can later decide whether to add:
validates :content, presence: true
But don’t add that yet.
There are legitimate AI API situations where a message may not have ordinary text content – for example, tool-related or structured content. We’ll revisit our message representation when we implement tool calling.
4.9 Test a valid message
Exit psql:
\q
Then:
bin/rails c
Run:
conversation = Conversation.first
Then:
message = conversation.messages.create( role: :user, content: "What is Ruby?")
Check:
message.persisted?
You should get:
true
And:
message.role
should return:
"user"
Stop Here
Please do these in order:
bin/rails generate migration AddMessageConstraints
Edit the migration with the constraints above.
Then:
bin/rails db:migrate
Verify with:
bin/rails dbconsole
\d messages
Then test the invalid role directly in PostgreSQL.
Finally:
git add app/models/message.rb db/migrategit commit -m "feat: validate message roles"git push
NOW: “Message constraints are done.”
Then we move to the big milestone: Our First Real LLM API Call
Excellent. We now have a clean foundation:
Ruby 3.4.1Rails 8.1PostgreSQLConversation │ └── Message ├── role ├── content ├── model ├── input_tokens └── output_tokensAi::Client
Now we reach the first real AI step.
Step 5 – Make Our First LLM API Call
We’re going to do this in a deliberately controlled way.
Don’t build the Chat UI yet.
First, we need to understand:
Ruby ↓Ai::Client ↓HTTP request ↓LLM provider ↓HTTP response ↓Ruby
Once we understand this, we’ll wrap it nicely into Rails architecture.
5.1 First decision – which provider?
For this practical course, let’s start with OpenAI.
Not because you must use OpenAI in production, but because it gives us a straightforward API to understand the fundamentals.
Later we’ll discuss:
Rails │ ├── OpenAI ├── Anthropic └── Gemini
and how to design our Ai::Client so that we’re not tightly coupled to one provider.
5.2 Before writing code – understand the request
Conceptually, we’re going to send something like:
POST /v1/responses{ "model": "...", "input": "Explain Ruby blocks in simple terms."}
The provider’s server processes the request:
Rails │ │ HTTPS ▼OpenAI API │ ▼LLM │ ▼Response
The important thing to understand is:
An LLM API is an HTTP API.
The Ruby SDK is just a convenient abstraction around HTTP.
5.3 Check our Ai::Client
You already created:
app/services/ai/client.rb
Open it.
If it currently contains nothing useful, that’s completely fine.
For now, make it:
# app/services/ai/client.rb
class Ai::Client
end
Don’t add API code yet.
5.4 Configure the API key securely
Do not put our API key in Ruby source code.
We have two common approaches:
Environment variables
or:
Rails encrypted credentials
For this project, I’m going to use Rails encrypted credentials because it’s a good opportunity to understand how Rails handles secrets.
5.5 Create Rails encrypted credentials
Run:
➜ ai_assistant git:(main) ✗ VISUAL="code --wait" rails credentials:edit
Rails will open our configured editor.
Add:
openai: api_key: OUR_OPENAI_API_KEY
For example:
openai: api_key: sk-xxxxxxxxxxxxxxxx
Use our actual API key locally, but never paste it into this conversation or commit it to GitHub.
Save and close the editor
What’s actually happening?
Rails creates/uses:
config/credentials.yml.enc
This file is encrypted.
Our encryption key is stored separately in:
config/master.key
The important rule is:
config/credentials.yml.enc ↓ COMMIT ↓ GitHub
is okay.
But:
config/master.key
should never be committed to GitHub.
Check:
git status
You should not see:
config/master.key
as a file to commit.
5.6 Verify Rails can read the key
Run:
bin/rails console
Then:
Rails.application.credentials.dig(:openai, :api_key)
You should get our key back, just verify that it returns a string rather than nil.
Then:
exit
5.7 Why use dig?
Our credentials structure is:
openai: api_key: ...
which Rails exposes approximately as:
{ openai: { api_key: "..." }}
So:
Rails.application.credentials.dig(:openai, :api_key)
means:
credentials ↓openai ↓api_key
This is cleaner than accessing nested values manually.
5.8 Now configure Ai::Client
Open:
app/services/ai/client.rb
Change it to:
class Ai::Client def initialize @api_key = Rails.application.credentials.dig(:openai, :api_key) endend
Now the client knows how to retrieve its secret.
5.9 Add a safety check
We don’t want the application to fail mysteriously later.
Add:
class Ai::Client
def initialize
@api_key = Rails.application.credentials.dig(:openai, :api_key)
raise "OpenAI API key is missing" if @api_key.blank?
end
end
Now:
Ai::Client.new
will fail immediately if the key isn’t configured. This is called fail-fast configuration.
5.10 Test the client
Run:
bin/rails console
Then:
client = Ai::Client.new
If everything is configured correctly, it should return:
#<Ai::Client:0x...>
No API request has happened yet.
We’re only testing:
Rails credentials ↓Ai::Client
Stop Here
Don’t make the API request yet.
complete only these steps first:
1. Configure credentials
bin/rails credentials:edit
with:
openai: api_key: OUR_KEY
2. Verify:
bin/rails console
Rails.application.credentials.dig(:openai, :api_key)
Don’t show me the key.
3. Update:
app/services/ai/client.rb
to:
class Ai::Client def initialize @api_key = Rails.application.credentials.dig(:openai, :api_key) raise "OpenAI API key is missing" if @api_key.blank? endend
4. Test:
client = Ai::Client.new
Now: “Ai::Client credentials is done.”
Next topic: Step 5.11: install/configure the OpenAI Ruby client and make the first actual LLM request.
to be continued ..